ソースを参照

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
 rejection) keep the subscription row and retry, since the endpoint
 may recover.
 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** (`/stacks`) are named collections of reference projects:
 **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/AdvisorService.vala',
     'src/Services/AdvisorCallback.vala',
     'src/Services/AdvisorCallback.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/SaddleApi.vala',
+    'src/Api/ProjectApi.vala',
     'src/Api/AdvisorApi.vala',
     'src/Api/AdvisorApi.vala',
     'src/Api/EventLogApi.vala',
     'src/Api/EventLogApi.vala',
     'src/Api/PushApi.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;
         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
      * Token magnitudes for the usage stat line (S5): millions and
      * tens-of-thousands read as one-decimal magnitudes ("45.2M",
      * tens-of-thousands read as one-decimal magnitudes ("45.2M",

+ 225 - 0
src/Services/ProjectService.vala

@@ -1,6 +1,7 @@
 using Invercargill;
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.DataStructures;
 using Invercargill.Expressions;
 using Invercargill.Expressions;
+using InvercargillSql;
 using InvercargillSql.Orm;
 using InvercargillSql.Orm;
 using Inversion;
 using Inversion;
 using ClankerSaddle.Data;
 using ClankerSaddle.Data;
@@ -19,6 +20,7 @@ namespace ClankerSaddle.Services {
     public class ProjectService : Object {
     public class ProjectService : Object {
 
 
         private OrmSession db = inject<OrmSession>();
         private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
 
 
         /** Inserts a project; `summary` may be empty. */
         /** Inserts a project; `summary` may be empty. */
         public async Project create_project(string name, string git_url,
         public async Project create_project(string name, string git_url,
@@ -176,5 +178,228 @@ namespace ClankerSaddle.Services {
                 yield db.delete_async<ProjectNote>((!)note);
                 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
      * exists, and saves an UPDATED markdown understanding via the
      * `save_understanding` tool. Refuses while another refresh task
      * `save_understanding` tool. Refuses while another refresh task
      * for the project is still live — one explorer at a time.
      * 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 ProjectService projects = inject<ProjectService>();
         private UnderstandingService understandings = inject<UnderstandingService>();
         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? attach_rule { get; set; }
         public Statum.Model.ActionDto? apply_stack { get; set; }
         public Statum.Model.ActionDto? apply_stack { get; set; }
         public Statum.Model.ActionDto? refresh_understanding { 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 };
             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() {
             var pub = new ProjectDetailPublic() {
                 exists = true,
                 exists = true,
                 id = ((!)project).id,
                 id = ((!)project).id,
@@ -212,7 +233,13 @@ namespace ClankerSaddle {
                 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),
                 attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
                 apply_stack = action_registry.author_private<ApplyStackToProjectAction, 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>();
             var links = new Vector<ProjectLinkView>();

+ 3 - 0
src/main.vala

@@ -147,6 +147,7 @@ int main(string[] args) {
         statum.action<UpdateProjectAction>();
         statum.action<UpdateProjectAction>();
         statum.action<AddProjectLinkAction>();
         statum.action<AddProjectLinkAction>();
         statum.action<RemoveProjectLinkAction>();
         statum.action<RemoveProjectLinkAction>();
+        statum.action<DeleteProjectAction>();
         statum.action<CreateTaskAction>();
         statum.action<CreateTaskAction>();
         statum.action<UpdateTaskAction>();
         statum.action<UpdateTaskAction>();
         statum.action<AddCommentAction>();
         statum.action<AddCommentAction>();
@@ -292,6 +293,8 @@ int main(string[] args) {
             new EndpointRoute("/api/advisor/chat/delete", Method.POST));
             new EndpointRoute("/api/advisor/chat/delete", Method.POST));
         application.add_endpoint<ClankerSaddle.Api.AdvisorChatDetailEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.AdvisorChatDetailEndpoint>(
             new EndpointRoute("/api/advisor/chat/{id}", Method.GET));
             new EndpointRoute("/api/advisor/chat/{id}", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.ProjectDeleteEndpoint>(
+            new EndpointRoute("/api/projects/{id}", Method.DELETE));
 
 
         GLib.Idle.add(() => {
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
             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>
                     <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>
                 </tbody>
             </table>
             </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>
         </div>
     </section>
     </section>
 </body>
 </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",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",
                  "canon",
+                 "projectdelete",
                  "canon_deploy")
                  "canon_deploy")