Переглянути джерело

Add admin-only project deletion (ownership cascade only)

DELETE /api/projects/{id} plus the project page's Delete project card
remove a project by OWNERSHIP CASCADE ONLY: the project row and the
rows it owns go in one transaction — its direct tasks (goal-run tasks
included), its goals, and those rows' children (task comments, blocks,
rule and capability attachments, notify prefs, sessions and the events
riding them, task/goal/project-scoped ledger rows, goal links, the
project's notes, understanding revisions, rule attachments, outgoing
links and stack memberships).

Work that merely references the deleted project is never touched:
another project's tasks, goals and rows survive even when linked via
co-developed/reference; only the now-dangling pointer rows
(project_links / goal_links entries naming the project) are tidied
away, and enqueue_after/goal_id references on surviving rows are
cleared instead of left dangling. Standalone tasks (project_id 0) and
the rule/capability/stack definitions stay.

Guards: the delete refuses with a clear error while the project still
has tasks queued, assigned, working, blocked or awaiting review
(HTTP 409 over the API); the confirmation dialog requires typing the
project's exact name and the server re-checks it, so a stale tab
cannot delete a renamed project. The dialog shows what will be removed
("3 goals, 9 tasks") and states that linked projects' work is left
untouched. Git branches/repos on saddles are out of scope.

The projectdelete e2e bundle covers the wire (401/403/404/409), the
full cascade, the cross-project survival law, standalone-task
immunity, the live-task guard and the typed-name gate.
saddle-ClankerSaddle 3 тижнів тому
батько
коміт
bd6c3b784f

+ 26 - 0
README.md

@@ -393,6 +393,32 @@ loudly, never silently claimed as delivered), endpoints answering
 rejection) keep the subscription row and retry, since the endpoint
 may recover.
 
+## Deleting a project
+
+**Admin-only**, from the project page's *Delete project* card (and the
+`DELETE /api/projects/{id}` JSON route). Deletion cascades by
+**ownership only**: the project row and the rows it owns — its direct
+tasks (goal-run tasks included), its goals, and those rows' children
+(comments, blocks, progress/log events, sessions, rule and capability
+attachments, the project's notes, understanding revisions, link rows
+and stack memberships) — all go in **one transaction**.
+
+Work that merely *references* the deleted project is **never touched**:
+another project's tasks, goals or rows that name it as a co-developed or
+reference link survive untouched; only the now-dangling link *entry*
+(project_links / goal_links) is tidied away. Standalone tasks
+(`project_id` 0) are never deleted either, and neither are the
+rule/capability/stack *definitions* — only attachment rows go.
+
+Two guards keep it safe: the delete **refuses while the project still
+has tasks queued, working or awaiting review** (HTTP 409 — cancel or let
+them finish first), and the confirmation dialog requires typing the
+project's **exact name** (the server re-checks it, so a stale tab cannot
+delete a renamed project). The dialog shows what will be removed
+("3 goals, 9 tasks") and says explicitly that linked projects' work is
+left alone. Git branches on saddles are out of scope — deleting the
+database rows never touches a clone.
+
 ## Stacks
 
 **Stacks** (`/stacks`) are named collections of reference projects:

+ 1 - 0
meson.build

@@ -250,6 +250,7 @@ app_sources = files(
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorCallback.vala',
     'src/Api/SaddleApi.vala',
+    'src/Api/ProjectApi.vala',
     'src/Api/AdvisorApi.vala',
     'src/Api/EventLogApi.vala',
     'src/Api/PushApi.vala',

+ 114 - 0
src/Api/ProjectApi.vala

@@ -0,0 +1,114 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql.Orm;
+using Inversion;
+using Astralis;
+using Soup;
+using Spry;
+using Spry.Authentication;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * The admin web API for projects. The saddle wire's `/api/saddle/*`
+     * endpoints authenticate by per-saddle bearer token; these routes
+     * authenticate the signed-in ADMIN the same way the advisor wire
+     * does — the browser's statum slot header resolves through the
+     * shared {@link Statum.HeldSlotResolver}, the auth slot's signed
+     * frame proves the session, and the user's permissions carry (or
+     * lack) the admin grant.
+     *
+     * DELETE /api/projects/{id} -> {ok}
+     *     401 no session · 403 not an admin · 404 unknown project ·
+     *     409 live tasks still under it · 200 gone
+     */
+    public class ProjectAdminApi : Object {
+
+        /**
+         * Resolves the calling admin through the auth slot, or null
+         * (with the HTTP result to answer already written to
+         * `error_out`). Mirrors the advisor wire's gate.
+         */
+        public static async UserProjection? resolve_admin(HttpContext http_context,
+                Statum.HeldSlotResolver resolver, UserService users,
+                out HttpResult? error_out) throws Error {
+            error_out = null;
+            string auth_username = "";
+            var resolved = resolver.resolve(http_context);
+            foreach (var pair in resolved.held) {
+                if (pair.value == null || ((!)pair.value).type_name != "auth") {
+                    continue;
+                }
+                var props = ((!)pair.value).@public;
+                if (props != null) {
+                    var name = Statum.GObjectMapping.get_string(((!)props), "username");
+                    auth_username = name ?? "";
+                }
+            }
+            if (auth_username.length == 0) {
+                error_out = SaddleWire.json_error(401, "Sign in as an administrator");
+                return null;
+            }
+            var user = yield users.get_user_by_username(auth_username);
+            if (user == null) {
+                error_out = SaddleWire.json_error(401, "Sign in as an administrator");
+                return null;
+            }
+            if (!SpryAuth.permission_matches(user.permissions.to_array(),
+                    SpryAuth.ADMIN_PERMISSION)) {
+                error_out = SaddleWire.json_error(403,
+                    "Deleting a project is an administrator action");
+                return null;
+            }
+            return user;
+        }
+    }
+
+    /**
+     * `DELETE /api/projects/{id}` (admin): removes the project by
+     * OWNERSHIP CASCADE ONLY — the project row and the rows it owns
+     * (its tasks, its goals, those rows' children). Linked projects'
+     * work is never touched; their link lists only lose the pointer
+     * row naming the deleted project. See {@link
+     * ProjectService.delete_project} for the full contract.
+     */
+    public class ProjectDeleteEndpoint : Object, Endpoint {
+
+        private ProjectService projects = inject<ProjectService>();
+        private Spry.Authentication.UserService users =
+            inject<Spry.Authentication.UserService>();
+        private Statum.HeldSlotResolver resolver = inject<Statum.HeldSlotResolver>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            HttpResult? guard = null;
+            var admin = yield ProjectAdminApi.resolve_admin(http_context, resolver,
+                users, out guard);
+            if (admin == null) {
+                return (!)guard;
+            }
+
+            string id_text = "";
+            route_context.mapped_parameters.try_get("id", out id_text);
+            int64 project_id = int64.parse(id_text);
+
+            try {
+                yield projects.delete_project(project_id);
+            } catch (Error e) {
+                // The service's two refusal faces, mapped onto the
+                // wire: unknown project vs live-task guard.
+                if (e.code == 40) {
+                    return SaddleWire.json_error(404, e.message);
+                }
+                if (e.code == 90) {
+                    return SaddleWire.json_error(409, e.message);
+                }
+                return SaddleWire.json_error(500,
+                    @"Could not delete the project: $(e.message)");
+            }
+            return SaddleWire.json_ok(new Json.Object());
+        }
+    }
+}

+ 8 - 0
src/Format.vala

@@ -28,6 +28,14 @@ namespace ClankerSaddle {
         return builder.str;
     }
 
+    /**
+     * A count with its pluralised noun ("1 goal", "3 tasks") — the
+     * deletion dialog's "what will be removed" line.
+     */
+    public string plural(int64 count, string noun) {
+        return @"$(count) $(noun)$(count == 1 ? "" : "s")";
+    }
+
     /**
      * Token magnitudes for the usage stat line (S5): millions and
      * tens-of-thousands read as one-decimal magnitudes ("45.2M",

+ 225 - 0
src/Services/ProjectService.vala

@@ -1,6 +1,7 @@
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.Expressions;
+using InvercargillSql;
 using InvercargillSql.Orm;
 using Inversion;
 using ClankerSaddle.Data;
@@ -19,6 +20,7 @@ namespace ClankerSaddle.Services {
     public class ProjectService : Object {
 
         private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
 
         /** Inserts a project; `summary` may be empty. */
         public async Project create_project(string name, string git_url,
@@ -176,5 +178,228 @@ namespace ClankerSaddle.Services {
                 yield db.delete_async<ProjectNote>((!)note);
             }
         }
+
+        // ------------------------------------------------------------------
+        // Deletion (ownership-only cascade)
+        // ------------------------------------------------------------------
+
+        /**
+         * The task statuses that keep {@link delete_project} refusing:
+         * a task still queued, assigned to a saddle, working, blocked
+         * or waiting on review has live work attached — deleting the
+         * project out from under it would strand a running daemon.
+         * ({@link STATUS_ASSIGNED} and {@link STATUS_BLOCKED} are
+         * included: both sit between queued and terminal.)
+         */
+        public const string[] ACTIVE_TASK_STATUSES = {
+            STATUS_QUEUED,
+            STATUS_ASSIGNED,
+            STATUS_WORKING,
+            STATUS_BLOCKED,
+            STATUS_REVIEW_REQUESTED
+        };
+
+        /**
+         * What {@link delete_project} will remove — the confirmation
+         * dialog's counts. `task_count` includes goal-run tasks; the
+         * page's visible task list hides them, so the dialog says so
+         * (they are the goal agents' own supervision rows, owned by
+         * the project's goals).
+         */
+        public class ProjectDeleteCounts : Object {
+            public int64 goal_count { get; set; }
+            public int64 task_count { get; set; }
+            public int64 active_task_count { get; set; }
+        }
+
+        /**
+         * Counts the rows the deletion would take (the dialog's
+         * "3 goals, 9 tasks"), plus how many tasks are still live
+         * (the guard's refusal message). Null when the project does
+         * not exist.
+         */
+        public async ProjectDeleteCounts? deletion_counts(int64 project_id) throws Error {
+            var project = yield get_project(project_id);
+            if (project == null) {
+                return null;
+            }
+            var counts = new ProjectDeleteCounts();
+            foreach (var row in yield db.query<Goal>()
+                    .where(expr("project_id == $0", elem<int64?>(project_id)))
+                    .materialise_async()) {
+                counts.goal_count++;
+            }
+            foreach (var task in yield db.query<TaskEntity>()
+                    .where(expr("project_id == $0", elem<int64?>(project_id)))
+                    .materialise_async()) {
+                counts.task_count++;
+                if (task.status in ACTIVE_TASK_STATUSES) {
+                    counts.active_task_count++;
+                }
+            }
+            return counts;
+        }
+
+        /**
+         * The rows a task deletion must take with it — everything that
+         * dangles under a task row: attached rules and capabilities,
+         * comments, blocks, notify prefs, the task's sessions and the
+         * events riding them, plus the events scoped to the task
+         * itself (web-originated rows carry no session). Bulk DELETEs
+         * over the task's id set; rules/capabilities themselves belong
+         * to the fleet, only the attachment rows go.
+         */
+        private async void delete_task_children(string task_ids) throws Error {
+            exec(@"DELETE FROM task_rules WHERE task_id IN ($(task_ids))");
+            exec(@"DELETE FROM task_capabilities WHERE task_id IN ($(task_ids))");
+            exec(@"DELETE FROM task_comments WHERE task_id IN ($(task_ids))");
+            exec(@"DELETE FROM task_blocks WHERE task_id IN ($(task_ids))");
+            exec(@"DELETE FROM task_notify_prefs WHERE task_id IN ($(task_ids))");
+            // Sessions carry the daemon-pushed events (events.session_id
+            // FK); the task-scoped ledger covers the web-originated rows.
+            exec(@"DELETE FROM events WHERE session_id IN"
+                + @" (SELECT id FROM sessions WHERE task_id IN ($(task_ids)))");
+            exec(@"DELETE FROM events WHERE scope_type = 'task' AND scope_id IN ($(task_ids))");
+            exec(@"DELETE FROM sessions WHERE task_id IN ($(task_ids))");
+            // A deleted predecessor must not strand its dependents in
+            // not-ready forever — the reference clears, the dependent
+            // row itself survives wherever it lives.
+            exec(@"UPDATE tasks SET enqueue_after_task_id = 0"
+                + @" WHERE enqueue_after_task_id IN ($(task_ids))");
+        }
+
+        /** Runs one SQL statement on the open transaction's connection. */
+        private void exec(string sql) throws Error {
+            connection.create_command(sql).execute_non_query();
+        }
+
+        /**
+         * Deletes a project by OWNERSHIP CASCADE ONLY — the project
+         * row and the rows the project owns: its direct tasks
+         * (`tasks.project_id`), its goals (`goals.project_id`), and
+         * those rows' children (task comments/blocks/rules/
+         * capabilities/notify prefs, the tasks' sessions and events,
+         * the goals' links and goal-scoped events, the project's
+         * links, notes, understandings and rule attachments, and the
+         * stack membership rows pointing at it).
+         *
+         * What it NEVER takes: another project's tasks, goals or
+         * project rows — even when they reference this project as a
+         * co-developed or reference link. Deleting a project only
+         * tidies those projects' POINTER rows (their `project_links`
+         * and `goal_links` entries naming this project); their work is
+         * untouchable. Standalone tasks (project_id 0) are never
+         * touched, and neither are stack rows (`stacks`) — only the
+         * membership pointer rows.
+         *
+         * Refuses (error code 90) while the project still has tasks
+         * in queued/assigned/working/blocked/review_requested: cancel
+         * or let them finish first. Everything runs in ONE
+         * transaction — a failure anywhere leaves the database
+         * exactly as it was.
+         */
+        public async void delete_project(int64 project_id) throws Error {
+            var project = yield get_project(project_id);
+            if (project == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 40,
+                    @"No project exists with id $(project_id)");
+            }
+
+            // Guard: refuse while live work sits under the project.
+            // Counted BEFORE the transaction opens (a refused delete
+            // should not have opened one at all).
+            var counts = yield deletion_counts(project_id);
+            if (counts != null && ((!)counts).active_task_count > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 90,
+                    @"The project still has $(((!)counts).active_task_count)"
+                    + @" task(s) queued, working or awaiting review — cancel or"
+                    + @" let them finish before deleting it");
+            }
+
+            // The id sets, read up front so the DELETEs inside the
+            // transaction are simple IN-lists (subqueries against
+            // rows the same transaction is deleting would race
+            // themselves).
+            var task_ids = yield owned_id_set("tasks", "project_id", project_id);
+            var goal_ids = yield owned_id_set("goals", "project_id", project_id);
+            string task_list = task_ids.length > 0 ? task_ids : "(0)";
+            string goal_list = goal_ids.length > 0 ? goal_ids : "(0)";
+
+            var transaction = connection.begin_transaction();
+            try {
+                // 1. Children of the project's tasks — before the
+                //    task rows they hang from.
+                yield delete_task_children(task_list);
+
+                // 2. Children of the project's goals: their link
+                //    pointers and the goal-scoped event ledger. The
+                //    goals' tasks were handled above (tasks carry
+                //    project_id, goal_id or both — the union of the
+                //    two id sets covers every row the project owns).
+                exec(@"DELETE FROM goal_links WHERE goal_id IN ($(goal_list))");
+                exec(@"DELETE FROM events WHERE scope_type = 'goal' AND scope_id IN ($(goal_list))");
+                // Project-scoped ledger rows (the scope constant exists
+                // though no writer emits them yet — when one appears,
+                // deleting the project must take them with it).
+                exec(@"DELETE FROM events WHERE scope_type = 'project' AND scope_id = $(project_id)");
+
+                // 3. The tasks, then the goals. A surviving row that
+                //    referenced one of the goals (goal tasks always
+                //    carry the goal's project_id, so this is purely
+                //    defensive) has its pointer cleared, not the row
+                //    taken.
+                exec(@"DELETE FROM tasks WHERE id IN ($(task_list))");
+                exec(@"DELETE FROM goals WHERE id IN ($(goal_list))");
+                exec(@"UPDATE tasks SET goal_id = 0 WHERE goal_id IN ($(goal_list))"
+                    + @" AND id NOT IN ($(task_list))");
+
+                // 4. The project's own rows: understandings, notes,
+                //    rule attachments, its outgoing links and the
+                //    stack membership pointers.
+                exec(@"DELETE FROM project_understandings WHERE project_id = $(project_id)");
+                exec(@"DELETE FROM project_notes WHERE project_id = $(project_id)");
+                exec(@"DELETE FROM rule_projects WHERE project_id = $(project_id)");
+                exec(@"DELETE FROM project_links WHERE parent_project_id = $(project_id)");
+                exec(@"DELETE FROM stack_projects WHERE project_id = $(project_id)");
+
+                // 5. POINTER tidy-up in OTHER projects (their work is
+                //    untouched): drop the link rows naming this
+                //    project as a co-developed/reference target, in
+                //    both the project and the goal link graphs.
+                exec(@"DELETE FROM project_links WHERE linked_project_id = $(project_id)");
+                exec(@"DELETE FROM goal_links WHERE project_id = $(project_id)");
+
+                // 6. The project row itself, last.
+                exec(@"DELETE FROM projects WHERE id = $(project_id)");
+
+                transaction.commit();
+            } catch (Error cascade_error) {
+                transaction.rollback();
+                throw cascade_error;
+            }
+        }
+
+        /**
+         * The comma-separated id list of `table` rows whose `column`
+         * equals `value` ("1,4,9"), for IN-lists. Empty when none —
+         * callers substitute "(0)". Only ever called with this
+         * service's own table/column literals.
+         */
+        private async string owned_id_set(string table, string column,
+                int64 value) throws Error {
+            var ids = new Vector<string>();
+            foreach (var row in yield connection.create_command(
+                    @"SELECT id FROM $(table) WHERE $(column) = :value")
+                .with_parameter<Element>("value", elem<int64?>(value))
+                .execute_query_async()) {
+                var id_element = row.get("id");
+                int64? id = 0;
+                if (id_element != null && ((!)id_element).try_get_as<int64?>(out id)
+                        && id != 0) {
+                    ids.add(((!)id).to_string());
+                }
+            }
+            return string.joinv(",", ids.to_array());
+        }
     }
 }

+ 62 - 2
src/actions/ProjectActions.vala

@@ -206,8 +206,7 @@ namespace ClankerSaddle {
      * exists, and saves an UPDATED markdown understanding via the
      * `save_understanding` tool. Refuses while another refresh task
      * for the project is still live — one explorer at a time.
-     */
-    public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
+     */    public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
 
         private ProjectService projects = inject<ProjectService>();
         private UnderstandingService understandings = inject<UnderstandingService>();
@@ -301,4 +300,65 @@ namespace ClankerSaddle {
             }
         }
     }
+
+    /**
+     * Deletes a project (admin-only), by OWNERSHIP CASCADE ONLY: the
+     * project row and the rows it owns — its tasks, its goals and
+     * those rows' children — die together; other projects' tasks,
+     * goals and rows are NEVER touched, even when they link to this
+     * project (their link lists only lose the pointer entry).
+     *
+     * The confirmation dialog must have typed the project's EXACT
+     * current name — the sealed id names the row, the form's
+     * `confirm_name` must equal the row's name at delete time, so a
+     * stale tab cannot delete a renamed project on one click.
+     * Refuses while the project still has tasks queued, working or
+     * awaiting review (the guard is re-checked server-side; see
+     * {@link ProjectService.delete_project}).
+     */
+    public class DeleteProjectAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        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_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null
+                ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            if (project_id <= 0) {
+                return directives().navigate("/projects");
+            }
+
+            var project = yield projects.get_project(project_id);
+            if (project == null) {
+                return directives().notify("error", "Project not found")
+                    .navigate("/projects");
+            }
+
+            // The typed-name gate: the dialog asks for the exact name.
+            if (Forms.field(request, "confirm_name").strip() != ((!)project).name) {
+                return directives().notify("error",
+                        "Type the project's exact name to confirm the deletion")
+                    .navigate(@"/projects/$(project_id)");
+            }
+
+            try {
+                yield projects.delete_project(project_id);
+                return directives().notify("success",
+                        @"Project '$(((!)project).name)' deleted — linked projects' work was left untouched")
+                    .navigate("/projects");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the project: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
 }

+ 28 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -169,6 +169,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? attach_rule { get; set; }
         public Statum.Model.ActionDto? apply_stack { get; set; }
         public Statum.Model.ActionDto? refresh_understanding { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+        /** True when the viewer may see the delete control (admin). */
+        public bool is_admin { get; set; }
+        /** What the delete would remove ("3 goals, 9 tasks"). */
+        public string delete_counts { get; set; default = ""; }
+        /** How many tasks are still live (the delete guard's refusal). */
+        public int64 active_tasks { get; set; }
     }
 
     /**
@@ -200,6 +207,20 @@ namespace ClankerSaddle {
             }
 
             var sealed = new ProjectActionPrivate() { project_id = project_id };
+            // The admin-only delete: authored only for admins (the
+            // dialog below stays hidden for everyone else), with the
+            // counts the confirmation shows.
+            bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
+            string delete_counts = "";
+            int64 active_tasks = 0;
+            if (is_admin) {
+                var counts = yield project_service.deletion_counts(project_id);
+                if (counts != null) {
+                    delete_counts = @"$(plural(((!)counts).goal_count, "goal"))"
+                        + @", $(plural(((!)counts).task_count, "task"))";
+                    active_tasks = ((!)counts).active_task_count;
+                }
+            }
             var pub = new ProjectDetailPublic() {
                 exists = true,
                 id = ((!)project).id,
@@ -212,7 +233,13 @@ namespace ClankerSaddle {
                 add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
                 attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
                 apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed),
-                refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed)
+                refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed),
+                delete = is_admin
+                    ? action_registry.author_private<DeleteProjectAction, ProjectActionPrivate>(sealed)
+                    : null,
+                is_admin = is_admin,
+                delete_counts = delete_counts,
+                active_tasks = active_tasks
             };
 
             var links = new Vector<ProjectLinkView>();

+ 3 - 0
src/main.vala

@@ -147,6 +147,7 @@ int main(string[] args) {
         statum.action<UpdateProjectAction>();
         statum.action<AddProjectLinkAction>();
         statum.action<RemoveProjectLinkAction>();
+        statum.action<DeleteProjectAction>();
         statum.action<CreateTaskAction>();
         statum.action<UpdateTaskAction>();
         statum.action<AddCommentAction>();
@@ -292,6 +293,8 @@ int main(string[] args) {
             new EndpointRoute("/api/advisor/chat/delete", Method.POST));
         application.add_endpoint<ClankerSaddle.Api.AdvisorChatDetailEndpoint>(
             new EndpointRoute("/api/advisor/chat/{id}", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.ProjectDeleteEndpoint>(
+            new EndpointRoute("/api/projects/{id}", Method.DELETE));
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);

+ 44 - 0
src/pages/project_detail.html

@@ -150,6 +150,50 @@
                     <tr stm-if="project.tasks.length == 0"><td colspan="4" class="muted">No tasks yet — create one from the <a href="/tasks">queue</a>.</td></tr>
                 </tbody>
             </table>
+
+            <!-- Admin-only deletion (ownership cascade). The dialog asks
+                 for the project's EXACT name — the server re-checks both
+                 the name and the live-task guard, so a stale tab cannot
+                 delete a renamed project or one with running work. -->
+            <div stm-if="project.is_admin" class="card">
+                <h3>Delete project</h3>
+                <p class="muted">Removing <strong stm-text="project.name">name</strong> also removes
+                    what it owns: <strong stm-text="project.delete_counts">0 goals, 0 tasks</strong>
+                    (tasks include the goal agents' own run rows), with their comments, progress
+                    and logs.</p>
+                <p stm-if="project.active_tasks > 0" class="muted">Deletion is refused while
+                    <span stm-text="project.active_tasks">0</span> task(s) are still queued, working
+                    or awaiting review — cancel or let them finish first.</p>
+                <p class="muted"><strong>Linked projects' work is untouched</strong> — projects,
+                    tasks and goals in other projects that reference this one as co-developed or
+                    reference are kept; only their link entry pointing here is removed.</p>
+                <button type="button" class="reject"
+                        onclick="document.getElementById('project-delete-dialog').showModal()">Delete
+                    project…</button>
+            </div>
+            <dialog id="project-delete-dialog">
+                <h3>Delete <span stm-text="project.name">name</span>?</h3>
+                <p>This permanently removes the project and everything it owns:
+                    <strong stm-text="project.delete_counts">0 goals, 0 tasks</strong>
+                    (with their comments, progress and logs).</p>
+                <p><strong>Linked projects' work is untouched.</strong> Other projects' tasks,
+                    goals and rows that merely reference this project survive — only their link
+                    entry pointing here goes.</p>
+                <p stm-if="project.active_tasks > 0" class="muted">Note: this project still has
+                    <span stm-text="project.active_tasks">0</span> live task(s) — the deletion
+                    will be refused until they are cancelled or finished.</p>
+                <p>Type the project name <strong stm-text="project.name">name</strong> to
+                    confirm:</p>
+                <form stm-action="project.delete">
+                    <input type="hidden" name="project_id" stm-attribute.value="project.id">
+                    <input type="text" name="confirm_name" autocomplete="off"
+                           stm-attribute.placeholder="'Type ' + project.name + ' to confirm'"
+                           style="width:100%; margin:0.5em 0;">
+                    <button type="submit" class="reject">Delete this project forever</button>
+                    <button type="button" class="inline"
+                            onclick="document.getElementById('project-delete-dialog').close()">Cancel</button>
+                </form>
+            </dialog>
         </div>
     </section>
 </body>

+ 461 - 0
test/bundles/projectdelete.py

@@ -0,0 +1,461 @@
+"""The projectdelete bundle: admin-only project deletion with a STRICTLY
+ownership-based cascade. The deletion takes the project row and the rows
+it owns (its tasks, its goals, and those rows' children); rows in OTHER
+projects that merely reference the deleted project (co-developed /
+reference links, stack membership) are NEVER taken — only the pointer
+rows naming it are tidied away. Covers the API wire (auth, 404, the
+live-task guard), the cascade's correctness, the cross-project survival
+law, standalone-task immunity, and the page's typed-name confirmation.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def sql_one(query, params=()):
+    """The first column of the first row, or None."""
+    conn = db()
+    try:
+        row = conn.execute(query, params).fetchone()
+        return row[0] if row is not None else None
+    finally:
+        conn.close()
+
+
+def sql_all(query, params=()):
+    conn = db()
+    try:
+        return conn.execute(query, params).fetchall()
+    finally:
+        conn.close()
+
+
+def sql_exec(*statements):
+    """Runs (sql, params...) statements in one commit; params are the
+    trailing tuple entry when present."""
+    conn = db()
+    try:
+        for statement in statements:
+            sql = statement[0]
+            params = statement[1] if len(statement) > 1 else ()
+            if not isinstance(params, (tuple, list)):
+                params = (params,)
+            conn.execute(sql, tuple(params))
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def make_project(name):
+    """Creates a project row straight in the DB, returning its id."""
+    sql_exec(("insert into projects (name, git_url, default_branch, created_at)"
+              " values (?, ?, 'main', strftime('%s','now'))",
+              (name, "https://example.com/%s.git" % name)))
+    return int(sql_one("select id from projects where name = ?", (name,)))
+
+
+def delete_api(auth, project_id):
+    """DELETE /api/projects/{id} with the caller's statum session."""
+    status, body = http("DELETE", "/api/projects/%d" % project_id, None, {
+        "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
+    return status, parse_json(body)
+
+
+def parse_json(text):
+    """A JSON object body, or {} when the body is empty or not JSON."""
+    try:
+        parsed = json.loads(text)
+        return parsed if isinstance(parsed, dict) else {}
+    except (ValueError, TypeError):
+        return {}
+
+
+def run(ctx):
+    auth, fake = ctx.auth, ctx.fake
+
+    print("== the API wire: auth, 404, method ==")
+    # Unauthenticated: no slot header at all.
+    status, body = http("DELETE", "/api/projects/1")
+    body = parse_json(body)
+    check(status == 401 and body.get("ok") is False,
+          "DELETE without a session answers 401 JSON")
+
+    # Unknown project id: 404 for the admin.
+    status, body = delete_api(auth, 999999)
+    check(status == 404 and "no project exists" in body.get("error", "").lower(),
+          "DELETE of an unknown project answers 404")
+
+    # A non-admin user (created through the users page) is refused 403
+    # even with a genuine session.
+    users_state = slot_public(auth, "/users", "users")
+    act(auth, users_state["create"], {
+        "username": "nonadmin", "email": "nonadmin@example.com",
+        "forename": "Non", "surname": "Admin",
+        "date_of_birth": "1990-01-01",
+        "password": "nonadmin1", "confirm_password": "nonadmin1"})
+    plain = login("nonadmin", "nonadmin1")
+    victim_id = make_project("auth-probe")
+    status, body = delete_api(plain, victim_id)
+    check(status == 403, "DELETE by a non-admin answers 403")
+    check(sql_one("select count(*) from projects where id = ?", (victim_id,)) == 1,
+          "the refused DELETE left the project row alone")
+    sql_exec(("delete from projects where id = ?", (victim_id,)))
+
+    print("== cascade: the project and its OWNED rows go, in full ==")
+    # The doomed project owns: two plain tasks (with comments, blocks,
+    # rule attachments, capability requirements, sessions + events) and
+    # one goal (with a goal-run task, goal links, goal-scoped events).
+    doomed = make_project("doomed")
+    keeper = make_project("keeper")
+    other = make_project("other-host")     # hosts cross-project survivors
+    standalone_host = make_project("standalone-host")
+
+    sql_exec(
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, 0, 'task', 'implement', 'human', 'Doomed draft',"
+         " 'x', 'draft', 5, strftime('%s','now'))", (doomed,)),
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, 0, 'task', 'implement', 'human', 'Doomed done',"
+         " 'x', 'done', 5, strftime('%s','now'))", (doomed,)),
+        ("insert into goals (project_id, title, brief, branch_name, status,"
+         " creator, created_at)"
+         " values (?, 'Doomed goal', 'Everything goes.', 'goal/doomed',"
+         " 'complete', 'human', strftime('%s','now'))", (doomed,)),
+    )
+    doomed_goal = int(sql_one("select id from goals where project_id = ?", (doomed,)))
+    sql_exec(
+        # The goal's own goal-run task (owned via project_id AND goal_id).
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, ?, 'goal_run', 'orchestrate', 'goal', 'Goal run',"
+         " 'x', 'done', 3, strftime('%s','now'))", (doomed, doomed_goal)),
+        # A goal_links row on the doomed goal pointing at `keeper`.
+        ("insert into goal_links (goal_id, project_id, relationship)"
+         " values (?, ?, 'reference')", (doomed_goal, keeper)),
+    )
+    doomed_tasks = [int(row[0]) for row in sql_all(
+        "select id from tasks where project_id = ?", (doomed,))]
+
+    # Child rows of the first doomed task: comment, block, rule attach,
+    # capability attach, a session and events (session-carried AND
+    # task-scoped).
+    first_task = doomed_tasks[0]
+    sql_exec(
+        ("insert into rules (name, body, created_at)"
+         " values ('doomed-rule', 'body', strftime('%s','now'))",),
+        ("insert into capabilities (name, created_at)"
+         " values ('doomed-cap', strftime('%s','now'))",),
+        ("insert into task_comments (task_id, author, body, created_at)"
+         " values (?, 'agent', 'a comment', strftime('%s','now'))", (first_task,)),
+        ("insert into task_blocks (task_id, question, created_at)"
+         " values (?, 'asked?', strftime('%s','now'))", (first_task,)),
+        ("insert into task_rules (rule_id, task_id, created_at)"
+         " values ((select id from rules where name = 'doomed-rule'), ?,"
+         " strftime('%s','now'))", (first_task,)),
+        ("insert into task_capabilities (capability_id, task_id)"
+         " values ((select id from capabilities where name = 'doomed-cap'), ?)",
+         (first_task,)),
+        ("insert into saddles (name, host, status, token)"
+         " values ('doomed-saddle', '127.0.0.1:9', 'idle', 't')",),
+        ("insert into sessions (task_id, saddle_id, started_at)"
+         " values (?, (select id from saddles where name = 'doomed-saddle'),"
+         " strftime('%s','now'))", (first_task,)),
+    )
+    session_id = int(sql_one("select id from sessions where task_id = ?", (first_task,)))
+    sql_exec(
+        ("insert into events (session_id, seq, kind, actor, scope_type,"
+         " scope_id, payload, created_at)"
+         " values (?, 1, 'turn_text', 'model', 'task', ?, '{}',"
+         " strftime('%s','now'))", (session_id, first_task)),
+        ("insert into events (session_id, seq, kind, actor, scope_type,"
+         " scope_id, payload, created_at)"
+         " values (null, null, 'comment', 'user', 'task', ?, '{}',"
+         " strftime('%s','now'))", (first_task,)),
+        ("insert into events (session_id, seq, kind, actor, scope_type,"
+         " scope_id, payload, created_at)"
+         " values (null, null, 'state_change', 'user', 'goal', ?, '{}',"
+         " strftime('%s','now'))", (doomed_goal,)),
+        ("insert into events (session_id, seq, kind, actor, scope_type,"
+         " scope_id, payload, created_at)"
+         " values (null, null, 'state_change', 'user', 'project', ?, '{}',"
+         " strftime('%s','now'))", (doomed,)),
+    )
+
+    # Project-owned rows: a note, an understanding revision, a rule
+    # attachment, the outgoing link to `keeper` and stack membership.
+    sql_exec(
+        ("insert into project_notes (project_id, source_task_id, body,"
+         " created_at) values (?, ?, 'note body', strftime('%s','now'))",
+         (doomed, first_task)),
+        ("insert into project_understandings (project_id, body, commit_hash,"
+         " task_id, created_at) values (?, 'understanding body', 'abc123', ?,"
+         " strftime('%s','now'))", (doomed, first_task)),
+        ("insert into rule_projects (rule_id, project_id, created_at)"
+         " values ((select id from rules where name = 'doomed-rule'), ?,"
+         " strftime('%s','now'))", (doomed,)),
+        ("insert into project_links (parent_project_id, linked_project_id,"
+         " relationship) values (?, ?, 'co-developed')", (doomed, keeper)),
+        ("insert into stacks (name, summary, created_at)"
+         " values ('doomed-stack', 's', strftime('%s','now'))",),
+        ("insert into stack_projects (stack_id, project_id, relationship,"
+         " created_at) values ((select id from stacks where name ="
+         " 'doomed-stack'), ?, 'reference', strftime('%s','now'))", (doomed,)),
+    )
+
+    print("== the survival law: OTHER projects' linked work is NEVER taken ==")
+    # `other-host` links to the doomed project (both directions of the
+    # pointer graph) and owns a task + goal whose rows merely NAME the
+    # doomed project through goal_links.
+    sql_exec(
+        ("insert into project_links (parent_project_id, linked_project_id,"
+         " relationship) values (?, ?, 'reference')", (other, doomed)),
+        ("insert into project_links (parent_project_id, linked_project_id,"
+         " relationship) values (?, ?, 'co-developed')", (other, doomed)),
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, 0, 'task', 'implement', 'human', 'Other project task',"
+         " 'x', 'done', 5, strftime('%s','now'))", (other,)),
+        ("insert into goals (project_id, title, brief, branch_name, status,"
+         " creator, created_at)"
+         " values (?, 'Other goal', 'b', 'goal/other', 'active', 'human',"
+         " strftime('%s','now'))", (other,)),
+    )
+    other_goal = int(sql_one("select id from goals where project_id = ?", (other,)))
+    other_task = int(sql_one(
+        "select id from tasks where project_id = ? and kind = 'task'", (other,)))
+    sql_exec(
+        # other's goal links TO the doomed project (goal_links naming it).
+        ("insert into goal_links (goal_id, project_id, relationship)"
+         " values (?, ?, 'co-developed')", (other_goal, doomed)),
+        # other's task depends on one of the doomed tasks as an
+        # auto-enqueue predecessor — the reference must CLEAR, not dangle.
+        ("update tasks set enqueue_after_task_id = ? where id = ?",
+         (first_task, other_task)),
+        # A standalone task (project 0) must never be touched.
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (0, 0, 'task', 'implement', 'human', 'Standalone task',"
+         " 'x', 'draft', 5, strftime('%s','now'))",),
+        # The keeper project links to the doomed project too — its link
+        # row must go, the project must stay.
+        ("insert into project_links (parent_project_id, linked_project_id,"
+         " relationship) values (?, ?, 'reference')", (keeper, doomed)),
+        # A goal on the KEEPER project linking the doomed project: the
+        # goal survives, only its link row goes.
+        ("insert into goals (project_id, title, brief, branch_name, status,"
+         " creator, created_at)"
+         " values (?, 'Keeper goal', 'b', 'goal/keeper', 'active', 'human',"
+         " strftime('%s','now'))", (keeper,)),
+        ("insert into goal_links (goal_id, project_id, relationship)"
+         " values ((select id from goals where project_id = ?), ?,"
+         " 'reference')", (keeper, doomed)),
+    )
+    standalone_task = int(sql_one(
+        "select id from tasks where project_id = 0 and title = 'Standalone task'"))
+
+    print("== the page: counts, admin-only control, typed-name gate ==")
+    state = slot_public(auth, "/projects/%d" % doomed, "project")
+    check(state["is_admin"] is True and state.get("delete") is not None,
+          "an admin's project page carries the delete action")
+    check(state["delete_counts"] == "1 goal, 3 tasks",
+          "the dialog counts what will go ('%s')" % state["delete_counts"])
+    check(state["active_tasks"] == 0,
+          "no live tasks under the doomed project (guard open)")
+
+    plain_state = slot_public(plain, "/projects/%d" % doomed, "project")
+    check(plain_state.get("delete") is None and plain_state.get("is_admin") is False,
+          "a non-admin's project page hides the delete action entirely")
+
+    # Wrong typed name refuses: nothing deleted.
+    result = act(auth, state["delete"],
+                 {"project_id": str(doomed), "confirm_name": "doome"})
+    check("exact name" in (action_notify(result) or ""),
+          "a wrong typed name refuses the deletion")
+    check(sql_one("select count(*) from projects where id = ?", (doomed,)) == 1,
+          "the refused confirmation left the project in place")
+
+    print("== the guard: live tasks refuse ==")
+    guard_project = make_project("guarded")
+    sql_exec(
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, 0, 'task', 'implement', 'human', 'Working task', 'x',"
+         " 'working', 5, strftime('%s','now'))", (guard_project,)),
+    )
+    guard_state = slot_public(auth, "/projects/%d" % guard_project, "project")
+    check(guard_state["active_tasks"] == 1,
+          "the page counts the live task (guard visible up front)")
+    status, body = delete_api(auth, guard_project)
+    check(status == 409 and "queued, working" in body.get("error", ""),
+          "the API refuses a live project with 409")
+    result = act(auth, guard_state["delete"],
+                 {"project_id": str(guard_project), "confirm_name": "guarded"})
+    notify = action_notify(result) or ""
+    check("still has 1 task" in notify or "still has 1 tasks" in notify,
+          "the action refuses too, naming the live count ('%s')" % notify)
+    check(sql_one("select count(*) from projects where id = ?", (guard_project,)) == 1,
+          "the guarded project survived")
+    # Drop the live task; now it goes.
+    sql_exec(("update tasks set status = 'done' where project_id = ?", (guard_project,)))
+    status, body = delete_api(auth, guard_project)
+    check(status == 200 and body.get("ok") is True,
+          "after the task settles, the same delete succeeds")
+
+    print("== the delete: owned rows gone, survivors intact ==")
+    status, body = delete_api(auth, doomed)
+    check(status == 200 and body.get("ok") is True,
+          "the doomed project deletes cleanly over the API")
+
+    def count(query, params=()):
+        return sql_one(query, params) or 0
+
+    # Gone: the project row, its tasks, its goal and every owned child.
+    check(count("select count(*) from projects where id = ?", (doomed,)) == 0,
+          "the project row is gone")
+    check(count("select count(*) from tasks where project_id = ?", (doomed,)) == 0,
+          "every owned task is gone (goal runs included)")
+    check(count("select count(*) from goals where project_id = ?", (doomed,)) == 0,
+          "every owned goal is gone")
+    check(count("select count(*) from task_comments where task_id in"
+                " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the owned tasks' comments are gone")
+    check(count("select count(*) from task_blocks where task_id in"
+                " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the owned tasks' blocks are gone")
+    check(count("select count(*) from task_rules where task_id in"
+                " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the owned tasks' rule attachments are gone")
+    check(count("select count(*) from task_capabilities where task_id in"
+                " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the owned tasks' capability requirements are gone")
+    check(count("select count(*) from sessions where task_id in"
+                " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the owned tasks' sessions are gone")
+    check(count("select count(*) from events where session_id = ?", (session_id,)) == 0,
+          "the session-carried events are gone")
+    check(count("select count(*) from events where scope_type = 'task' and"
+                " scope_id in (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
+          "the task-scoped events are gone")
+    check(count("select count(*) from events where scope_type = 'goal' and"
+                " scope_id = ?", (doomed_goal,)) == 0,
+          "the goal-scoped events are gone")
+    check(count("select count(*) from events where scope_type = 'project' and"
+                " scope_id = ?", (doomed,)) == 0,
+          "the project-scoped events are gone")
+    check(count("select count(*) from goal_links where goal_id = ?", (doomed_goal,)) == 0,
+          "the doomed goal's link rows are gone")
+    check(count("select count(*) from project_notes where project_id = ?", (doomed,)) == 0,
+          "the project's notes are gone")
+    check(count("select count(*) from project_understandings where project_id = ?",
+                (doomed,)) == 0,
+          "the project's understanding revisions are gone")
+    check(count("select count(*) from rule_projects where project_id = ?", (doomed,)) == 0,
+          "the project's rule attachments are gone")
+    check(count("select count(*) from project_links where parent_project_id = ?",
+                (doomed,)) == 0,
+          "the project's outgoing links are gone")
+    check(count("select count(*) from stack_projects where project_id = ?",
+                (doomed,)) == 0,
+          "the project's stack memberships are gone")
+
+    # Intact: everything NOT owned by the doomed project.
+    check(count("select count(*) from projects where id = ?", (other,)) == 1
+          and count("select count(*) from projects where id = ?", (keeper,)) == 1,
+          "the other projects' rows survive")
+    check(count("select count(*) from tasks where id = ?", (other_task,)) == 1,
+          "CRITICAL: another project's task that linked to the doomed project SURVIVES")
+    check(count("select count(*) from goals where id = ?", (other_goal,)) == 1,
+          "CRITICAL: another project's goal that linked to the doomed project SURVIVES")
+    check(count("select count(*) from tasks where id = ?", (standalone_task,)) == 1,
+          "standalone tasks (project 0) are untouched")
+    check(sql_one("select enqueue_after_task_id from tasks where id = ?",
+                  (other_task,)) == 0,
+          "the survivor's enqueue-after reference to a deleted task is CLEARED (not dangling)")
+    check(count("select count(*) from project_links where parent_project_id = ?"
+                " and linked_project_id = ?", (other, doomed)) == 0,
+          "other project's link rows NAMING the doomed project are tidied away (pointer only)")
+    check(count("select count(*) from project_links where parent_project_id = ?"
+                " and linked_project_id = ?", (keeper, doomed)) == 0,
+          "the keeper's link row naming the doomed project is tidied away")
+    check(count("select count(*) from goal_links where project_id = ?", (doomed,)) == 0,
+          "goal link rows in OTHER projects naming the doomed project are tidied (pointer only)")
+    check(count("select count(*) from goal_links where goal_id = ?",
+                (other_goal,)) == 0,
+          "the other project's goal lost only its link row to the doomed project")
+    keeper_goal = int(sql_one("select id from goals where project_id = ?", (keeper,)))
+    check(count("select count(*) from goals where id = ?", (keeper_goal,)) == 1,
+          "the keeper's own goal survived the pointer tidy")
+    # Fleet-owned rows the cascade must never take.
+    check(count("select count(*) from rules where name = 'doomed-rule'") == 1,
+          "the rule DEFINITIONS survive (only attachments went)")
+    check(count("select count(*) from capabilities where name = 'doomed-cap'") == 1,
+          "the capability DEFINITIONS survive (only requirements went)")
+    check(count("select count(*) from stacks where name = 'doomed-stack'") == 1,
+          "the stack itself survives (only its membership row went)")
+    check(count("select count(*) from saddles where name = 'doomed-saddle'") == 1,
+          "the saddle registration survives")
+
+    print("== the page after the delete ==")
+    # The detail page of a deleted project reports not-found.
+    state = slot_public(auth, "/projects/%d" % doomed, "project")
+    check(state["exists"] is False,
+          "the deleted project's page reports not found")
+    names = [p["name"] for p in slot_public(auth, "/projects", "projects")["projects"]]
+    check("doomed" not in names and "keeper" in names,
+          "the projects list shows the survivors only")
+
+    print("== the UI path: the action deletes and redirects ==")
+    # End-to-end through the page action (the dialog's form): a fresh
+    # project with one goal and one task goes, the reply navigates to
+    # /projects with a success notify.
+    ui_doomed = make_project("ui-doomed")
+    sql_exec(
+        ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
+         " description, status, priority, created_at)"
+         " values (?, 0, 'task', 'implement', 'human', 'UI task', 'x',"
+         " 'done', 5, strftime('%s','now'))", (ui_doomed,)),
+        ("insert into goals (project_id, title, brief, branch_name, status,"
+         " creator, created_at)"
+         " values (?, 'UI goal', 'b', 'goal/ui', 'complete', 'human',"
+         " strftime('%s','now'))", (ui_doomed,)),
+    )
+    ui_state = slot_public(auth, "/projects/%d" % ui_doomed, "project")
+    check(ui_state["delete_counts"] == "1 goal, 1 task",
+          "the UI project's counts read '1 goal, 1 task' ('%s')"
+          % ui_state["delete_counts"])
+    result = act(auth, ui_state["delete"],
+                 {"project_id": str(ui_doomed), "confirm_name": "ui-doomed"})
+    notify = action_notify(result) or ""
+    check("deleted" in notify and "untouched" in notify,
+          "the action reports the delete and the survival guarantee ('%s')" % notify)
+    check(any(d.get("type") == "navigate" and d.get("uri") == "/projects"
+              for d in result), "the action redirects to the projects list")
+    check(count("select count(*) from projects where id = ?", (ui_doomed,)) == 0
+          and count("select count(*) from tasks where project_id = ?", (ui_doomed,)) == 0
+          and count("select count(*) from goals where project_id = ?", (ui_doomed,)) == 0,
+          "the UI delete took the project with its task and goal")
+
+    # Idempotence of the wire: deleting the same project again is a 404.
+    status, body = delete_api(auth, doomed)
+    check(status == 404, "deleting an already-deleted project answers 404")
+
+    # Housekeeping: the leftover helper rows this bundle created.
+    sql_exec(
+        ("delete from goal_links where goal_id in (select id from goals where"
+         " project_id in (?, ?))", (keeper, standalone_host)),
+        ("delete from goals where project_id in (?, ?)", (keeper, standalone_host)),
+        ("delete from tasks where id = ?", (standalone_task,)),
+        ("delete from tasks where project_id = ?", (other,)),
+        ("delete from goal_links where goal_id = ?", (other_goal,)),
+        ("delete from goals where id = ?", (other_goal,)),
+        ("delete from project_links where parent_project_id in (?, ?)",
+         (other, keeper)),
+        ("delete from stack_projects where stack_id ="
+          " (select id from stacks where name = 'doomed-stack')",),
+        ("delete from stacks where name = 'doomed-stack'",),
+        ("delete from rules where name = 'doomed-rule'",),
+        ("delete from capabilities where name = 'doomed-cap'",),
+        ("delete from saddles where name = 'doomed-saddle'",),
+        ("delete from projects where id in (?, ?, ?)", (other, keeper,
+                                                         standalone_host)),
+    )

+ 1 - 0
test/harness.py

@@ -1371,6 +1371,7 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",
+                 "projectdelete",
                  "canon_deploy")