projectdelete.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. """The projectdelete bundle: admin-only project deletion with a STRICTLY
  2. ownership-based cascade. The deletion takes the project row and the rows
  3. it owns (its tasks, its goals, and those rows' children); rows in OTHER
  4. projects that merely reference the deleted project (co-developed /
  5. reference links, stack membership) are NEVER taken — only the pointer
  6. rows naming it are tidied away. Covers the API wire (auth, 404, the
  7. live-task guard), the cascade's correctness, the cross-project survival
  8. law, standalone-task immunity, and the page's typed-name confirmation.
  9. """
  10. from harness import * # noqa: F401,F403 — the shared wire helpers
  11. def sql_one(query, params=()):
  12. """The first column of the first row, or None."""
  13. conn = db()
  14. try:
  15. row = conn.execute(query, params).fetchone()
  16. return row[0] if row is not None else None
  17. finally:
  18. conn.close()
  19. def sql_all(query, params=()):
  20. conn = db()
  21. try:
  22. return conn.execute(query, params).fetchall()
  23. finally:
  24. conn.close()
  25. def sql_exec(*statements):
  26. """Runs (sql, params...) statements in one commit; params are the
  27. trailing tuple entry when present."""
  28. conn = db()
  29. try:
  30. for statement in statements:
  31. sql = statement[0]
  32. params = statement[1] if len(statement) > 1 else ()
  33. if not isinstance(params, (tuple, list)):
  34. params = (params,)
  35. conn.execute(sql, tuple(params))
  36. conn.commit()
  37. finally:
  38. conn.close()
  39. def make_project(name):
  40. """Creates a project row straight in the DB, returning its id."""
  41. sql_exec(("insert into projects (name, git_url, default_branch, created_at)"
  42. " values (?, ?, 'main', strftime('%s','now'))",
  43. (name, "https://example.com/%s.git" % name)))
  44. return int(sql_one("select id from projects where name = ?", (name,)))
  45. def delete_api(auth, project_id):
  46. """DELETE /api/projects/{id} with the caller's statum session."""
  47. status, body = http("DELETE", "/api/projects/%d" % project_id, None, {
  48. "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
  49. return status, parse_json(body)
  50. def parse_json(text):
  51. """A JSON object body, or {} when the body is empty or not JSON."""
  52. try:
  53. parsed = json.loads(text)
  54. return parsed if isinstance(parsed, dict) else {}
  55. except (ValueError, TypeError):
  56. return {}
  57. def run(ctx):
  58. auth, fake = ctx.auth, ctx.fake
  59. print("== the API wire: auth, 404, method ==")
  60. # Unauthenticated: no slot header at all.
  61. status, body = http("DELETE", "/api/projects/1")
  62. body = parse_json(body)
  63. check(status == 401 and body.get("ok") is False,
  64. "DELETE without a session answers 401 JSON")
  65. # Unknown project id: 404 for the admin.
  66. status, body = delete_api(auth, 999999)
  67. check(status == 404 and "no project exists" in body.get("error", "").lower(),
  68. "DELETE of an unknown project answers 404")
  69. # A non-admin user (created through the users page) is refused 403
  70. # even with a genuine session.
  71. users_state = slot_public(auth, "/users", "users")
  72. act(auth, users_state["create"], {
  73. "username": "nonadmin", "email": "nonadmin@example.com",
  74. "forename": "Non", "surname": "Admin",
  75. "date_of_birth": "1990-01-01",
  76. "password": "nonadmin1", "confirm_password": "nonadmin1"})
  77. plain = login("nonadmin", "nonadmin1")
  78. victim_id = make_project("auth-probe")
  79. status, body = delete_api(plain, victim_id)
  80. check(status == 403, "DELETE by a non-admin answers 403")
  81. check(sql_one("select count(*) from projects where id = ?", (victim_id,)) == 1,
  82. "the refused DELETE left the project row alone")
  83. sql_exec(("delete from projects where id = ?", (victim_id,)))
  84. print("== cascade: the project and its OWNED rows go, in full ==")
  85. # The doomed project owns: two plain tasks (with comments, blocks,
  86. # rule attachments, capability requirements, sessions + events) and
  87. # one goal (with a goal-run task, goal links, goal-scoped events).
  88. doomed = make_project("doomed")
  89. keeper = make_project("keeper")
  90. other = make_project("other-host") # hosts cross-project survivors
  91. standalone_host = make_project("standalone-host")
  92. sql_exec(
  93. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  94. " description, status, priority, created_at)"
  95. " values (?, 0, 'task', 'implement', 'human', 'Doomed draft',"
  96. " 'x', 'draft', 5, strftime('%s','now'))", (doomed,)),
  97. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  98. " description, status, priority, created_at)"
  99. " values (?, 0, 'task', 'implement', 'human', 'Doomed done',"
  100. " 'x', 'done', 5, strftime('%s','now'))", (doomed,)),
  101. ("insert into goals (project_id, title, brief, branch_name, status,"
  102. " creator, created_at)"
  103. " values (?, 'Doomed goal', 'Everything goes.', 'goal/doomed',"
  104. " 'complete', 'human', strftime('%s','now'))", (doomed,)),
  105. )
  106. doomed_goal = int(sql_one("select id from goals where project_id = ?", (doomed,)))
  107. sql_exec(
  108. # The goal's own goal-run task (owned via project_id AND goal_id).
  109. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  110. " description, status, priority, created_at)"
  111. " values (?, ?, 'goal_run', 'orchestrate', 'goal', 'Goal run',"
  112. " 'x', 'done', 3, strftime('%s','now'))", (doomed, doomed_goal)),
  113. # A goal_links row on the doomed goal pointing at `keeper`.
  114. ("insert into goal_links (goal_id, project_id, relationship)"
  115. " values (?, ?, 'reference')", (doomed_goal, keeper)),
  116. )
  117. doomed_tasks = [int(row[0]) for row in sql_all(
  118. "select id from tasks where project_id = ?", (doomed,))]
  119. # Child rows of the first doomed task: comment, block, rule attach,
  120. # capability attach, a session and events (session-carried AND
  121. # task-scoped).
  122. first_task = doomed_tasks[0]
  123. sql_exec(
  124. ("insert into rules (name, body, created_at)"
  125. " values ('doomed-rule', 'body', strftime('%s','now'))",),
  126. ("insert into capabilities (name, created_at)"
  127. " values ('doomed-cap', strftime('%s','now'))",),
  128. ("insert into task_comments (task_id, author, body, created_at)"
  129. " values (?, 'agent', 'a comment', strftime('%s','now'))", (first_task,)),
  130. ("insert into task_blocks (task_id, question, created_at)"
  131. " values (?, 'asked?', strftime('%s','now'))", (first_task,)),
  132. ("insert into task_rules (rule_id, task_id, created_at)"
  133. " values ((select id from rules where name = 'doomed-rule'), ?,"
  134. " strftime('%s','now'))", (first_task,)),
  135. ("insert into task_capabilities (capability_id, task_id)"
  136. " values ((select id from capabilities where name = 'doomed-cap'), ?)",
  137. (first_task,)),
  138. ("insert into saddles (name, host, status, token)"
  139. " values ('doomed-saddle', '127.0.0.1:9', 'idle', 't')",),
  140. ("insert into sessions (task_id, saddle_id, started_at)"
  141. " values (?, (select id from saddles where name = 'doomed-saddle'),"
  142. " strftime('%s','now'))", (first_task,)),
  143. )
  144. session_id = int(sql_one("select id from sessions where task_id = ?", (first_task,)))
  145. sql_exec(
  146. ("insert into events (session_id, seq, kind, actor, scope_type,"
  147. " scope_id, payload, created_at)"
  148. " values (?, 1, 'turn_text', 'model', 'task', ?, '{}',"
  149. " strftime('%s','now'))", (session_id, first_task)),
  150. ("insert into events (session_id, seq, kind, actor, scope_type,"
  151. " scope_id, payload, created_at)"
  152. " values (null, null, 'comment', 'user', 'task', ?, '{}',"
  153. " strftime('%s','now'))", (first_task,)),
  154. ("insert into events (session_id, seq, kind, actor, scope_type,"
  155. " scope_id, payload, created_at)"
  156. " values (null, null, 'state_change', 'user', 'goal', ?, '{}',"
  157. " strftime('%s','now'))", (doomed_goal,)),
  158. ("insert into events (session_id, seq, kind, actor, scope_type,"
  159. " scope_id, payload, created_at)"
  160. " values (null, null, 'state_change', 'user', 'project', ?, '{}',"
  161. " strftime('%s','now'))", (doomed,)),
  162. )
  163. # Project-owned rows: a note, an understanding revision, a rule
  164. # attachment, the outgoing link to `keeper` and stack membership.
  165. sql_exec(
  166. ("insert into project_notes (project_id, source_task_id, body,"
  167. " created_at) values (?, ?, 'note body', strftime('%s','now'))",
  168. (doomed, first_task)),
  169. ("insert into project_understandings (project_id, body, commit_hash,"
  170. " task_id, created_at) values (?, 'understanding body', 'abc123', ?,"
  171. " strftime('%s','now'))", (doomed, first_task)),
  172. ("insert into rule_projects (rule_id, project_id, created_at)"
  173. " values ((select id from rules where name = 'doomed-rule'), ?,"
  174. " strftime('%s','now'))", (doomed,)),
  175. ("insert into project_links (parent_project_id, linked_project_id,"
  176. " relationship) values (?, ?, 'co-developed')", (doomed, keeper)),
  177. ("insert into stacks (name, summary, created_at)"
  178. " values ('doomed-stack', 's', strftime('%s','now'))",),
  179. ("insert into stack_projects (stack_id, project_id, relationship,"
  180. " created_at) values ((select id from stacks where name ="
  181. " 'doomed-stack'), ?, 'reference', strftime('%s','now'))", (doomed,)),
  182. )
  183. print("== the survival law: OTHER projects' linked work is NEVER taken ==")
  184. # `other-host` links to the doomed project (both directions of the
  185. # pointer graph) and owns a task + goal whose rows merely NAME the
  186. # doomed project through goal_links.
  187. sql_exec(
  188. ("insert into project_links (parent_project_id, linked_project_id,"
  189. " relationship) values (?, ?, 'reference')", (other, doomed)),
  190. ("insert into project_links (parent_project_id, linked_project_id,"
  191. " relationship) values (?, ?, 'co-developed')", (other, doomed)),
  192. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  193. " description, status, priority, created_at)"
  194. " values (?, 0, 'task', 'implement', 'human', 'Other project task',"
  195. " 'x', 'done', 5, strftime('%s','now'))", (other,)),
  196. ("insert into goals (project_id, title, brief, branch_name, status,"
  197. " creator, created_at)"
  198. " values (?, 'Other goal', 'b', 'goal/other', 'active', 'human',"
  199. " strftime('%s','now'))", (other,)),
  200. )
  201. other_goal = int(sql_one("select id from goals where project_id = ?", (other,)))
  202. other_task = int(sql_one(
  203. "select id from tasks where project_id = ? and kind = 'task'", (other,)))
  204. sql_exec(
  205. # other's goal links TO the doomed project (goal_links naming it).
  206. ("insert into goal_links (goal_id, project_id, relationship)"
  207. " values (?, ?, 'co-developed')", (other_goal, doomed)),
  208. # other's task depends on one of the doomed tasks as an
  209. # auto-enqueue predecessor — the reference must CLEAR, not dangle.
  210. ("update tasks set enqueue_after_task_id = ? where id = ?",
  211. (first_task, other_task)),
  212. # A standalone task (project 0) must never be touched.
  213. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  214. " description, status, priority, created_at)"
  215. " values (0, 0, 'task', 'implement', 'human', 'Standalone task',"
  216. " 'x', 'draft', 5, strftime('%s','now'))",),
  217. # The keeper project links to the doomed project too — its link
  218. # row must go, the project must stay.
  219. ("insert into project_links (parent_project_id, linked_project_id,"
  220. " relationship) values (?, ?, 'reference')", (keeper, doomed)),
  221. # A goal on the KEEPER project linking the doomed project: the
  222. # goal survives, only its link row goes.
  223. ("insert into goals (project_id, title, brief, branch_name, status,"
  224. " creator, created_at)"
  225. " values (?, 'Keeper goal', 'b', 'goal/keeper', 'active', 'human',"
  226. " strftime('%s','now'))", (keeper,)),
  227. ("insert into goal_links (goal_id, project_id, relationship)"
  228. " values ((select id from goals where project_id = ?), ?,"
  229. " 'reference')", (keeper, doomed)),
  230. )
  231. standalone_task = int(sql_one(
  232. "select id from tasks where project_id = 0 and title = 'Standalone task'"))
  233. print("== the page: counts, admin-only control, typed-name gate ==")
  234. state = slot_public(auth, "/projects/%d" % doomed, "project")
  235. check(state["is_admin"] is True and state.get("delete") is not None,
  236. "an admin's project page carries the delete action")
  237. check(state["delete_counts"] == "1 goal, 3 tasks",
  238. "the dialog counts what will go ('%s')" % state["delete_counts"])
  239. check(state["active_tasks"] == 0,
  240. "no live tasks under the doomed project (guard open)")
  241. plain_state = slot_public(plain, "/projects/%d" % doomed, "project")
  242. check(plain_state.get("delete") is None and plain_state.get("is_admin") is False,
  243. "a non-admin's project page hides the delete action entirely")
  244. # Wrong typed name refuses: nothing deleted.
  245. result = act(auth, state["delete"],
  246. {"project_id": str(doomed), "confirm_name": "doome"})
  247. check("exact name" in (action_notify(result) or ""),
  248. "a wrong typed name refuses the deletion")
  249. check(sql_one("select count(*) from projects where id = ?", (doomed,)) == 1,
  250. "the refused confirmation left the project in place")
  251. print("== the guard: live tasks refuse ==")
  252. guard_project = make_project("guarded")
  253. sql_exec(
  254. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  255. " description, status, priority, created_at)"
  256. " values (?, 0, 'task', 'implement', 'human', 'Working task', 'x',"
  257. " 'working', 5, strftime('%s','now'))", (guard_project,)),
  258. )
  259. guard_state = slot_public(auth, "/projects/%d" % guard_project, "project")
  260. check(guard_state["active_tasks"] == 1,
  261. "the page counts the live task (guard visible up front)")
  262. status, body = delete_api(auth, guard_project)
  263. check(status == 409 and "queued, working" in body.get("error", ""),
  264. "the API refuses a live project with 409")
  265. result = act(auth, guard_state["delete"],
  266. {"project_id": str(guard_project), "confirm_name": "guarded"})
  267. notify = action_notify(result) or ""
  268. check("still has 1 task" in notify or "still has 1 tasks" in notify,
  269. "the action refuses too, naming the live count ('%s')" % notify)
  270. check(sql_one("select count(*) from projects where id = ?", (guard_project,)) == 1,
  271. "the guarded project survived")
  272. # Drop the live task; now it goes.
  273. sql_exec(("update tasks set status = 'done' where project_id = ?", (guard_project,)))
  274. status, body = delete_api(auth, guard_project)
  275. check(status == 200 and body.get("ok") is True,
  276. "after the task settles, the same delete succeeds")
  277. print("== the delete: owned rows gone, survivors intact ==")
  278. status, body = delete_api(auth, doomed)
  279. check(status == 200 and body.get("ok") is True,
  280. "the doomed project deletes cleanly over the API")
  281. def count(query, params=()):
  282. return sql_one(query, params) or 0
  283. # Gone: the project row, its tasks, its goal and every owned child.
  284. check(count("select count(*) from projects where id = ?", (doomed,)) == 0,
  285. "the project row is gone")
  286. check(count("select count(*) from tasks where project_id = ?", (doomed,)) == 0,
  287. "every owned task is gone (goal runs included)")
  288. check(count("select count(*) from goals where project_id = ?", (doomed,)) == 0,
  289. "every owned goal is gone")
  290. check(count("select count(*) from task_comments where task_id in"
  291. " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  292. "the owned tasks' comments are gone")
  293. check(count("select count(*) from task_blocks where task_id in"
  294. " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  295. "the owned tasks' blocks are gone")
  296. check(count("select count(*) from task_rules where task_id in"
  297. " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  298. "the owned tasks' rule attachments are gone")
  299. check(count("select count(*) from task_capabilities where task_id in"
  300. " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  301. "the owned tasks' capability requirements are gone")
  302. check(count("select count(*) from sessions where task_id in"
  303. " (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  304. "the owned tasks' sessions are gone")
  305. check(count("select count(*) from events where session_id = ?", (session_id,)) == 0,
  306. "the session-carried events are gone")
  307. check(count("select count(*) from events where scope_type = 'task' and"
  308. " scope_id in (%s)" % ",".join(str(t) for t in doomed_tasks)) == 0,
  309. "the task-scoped events are gone")
  310. check(count("select count(*) from events where scope_type = 'goal' and"
  311. " scope_id = ?", (doomed_goal,)) == 0,
  312. "the goal-scoped events are gone")
  313. check(count("select count(*) from events where scope_type = 'project' and"
  314. " scope_id = ?", (doomed,)) == 0,
  315. "the project-scoped events are gone")
  316. check(count("select count(*) from goal_links where goal_id = ?", (doomed_goal,)) == 0,
  317. "the doomed goal's link rows are gone")
  318. check(count("select count(*) from project_notes where project_id = ?", (doomed,)) == 0,
  319. "the project's notes are gone")
  320. check(count("select count(*) from project_understandings where project_id = ?",
  321. (doomed,)) == 0,
  322. "the project's understanding revisions are gone")
  323. check(count("select count(*) from rule_projects where project_id = ?", (doomed,)) == 0,
  324. "the project's rule attachments are gone")
  325. check(count("select count(*) from project_links where parent_project_id = ?",
  326. (doomed,)) == 0,
  327. "the project's outgoing links are gone")
  328. check(count("select count(*) from stack_projects where project_id = ?",
  329. (doomed,)) == 0,
  330. "the project's stack memberships are gone")
  331. # Intact: everything NOT owned by the doomed project.
  332. check(count("select count(*) from projects where id = ?", (other,)) == 1
  333. and count("select count(*) from projects where id = ?", (keeper,)) == 1,
  334. "the other projects' rows survive")
  335. check(count("select count(*) from tasks where id = ?", (other_task,)) == 1,
  336. "CRITICAL: another project's task that linked to the doomed project SURVIVES")
  337. check(count("select count(*) from goals where id = ?", (other_goal,)) == 1,
  338. "CRITICAL: another project's goal that linked to the doomed project SURVIVES")
  339. check(count("select count(*) from tasks where id = ?", (standalone_task,)) == 1,
  340. "standalone tasks (project 0) are untouched")
  341. check(sql_one("select enqueue_after_task_id from tasks where id = ?",
  342. (other_task,)) == 0,
  343. "the survivor's enqueue-after reference to a deleted task is CLEARED (not dangling)")
  344. check(count("select count(*) from project_links where parent_project_id = ?"
  345. " and linked_project_id = ?", (other, doomed)) == 0,
  346. "other project's link rows NAMING the doomed project are tidied away (pointer only)")
  347. check(count("select count(*) from project_links where parent_project_id = ?"
  348. " and linked_project_id = ?", (keeper, doomed)) == 0,
  349. "the keeper's link row naming the doomed project is tidied away")
  350. check(count("select count(*) from goal_links where project_id = ?", (doomed,)) == 0,
  351. "goal link rows in OTHER projects naming the doomed project are tidied (pointer only)")
  352. check(count("select count(*) from goal_links where goal_id = ?",
  353. (other_goal,)) == 0,
  354. "the other project's goal lost only its link row to the doomed project")
  355. keeper_goal = int(sql_one("select id from goals where project_id = ?", (keeper,)))
  356. check(count("select count(*) from goals where id = ?", (keeper_goal,)) == 1,
  357. "the keeper's own goal survived the pointer tidy")
  358. # Fleet-owned rows the cascade must never take.
  359. check(count("select count(*) from rules where name = 'doomed-rule'") == 1,
  360. "the rule DEFINITIONS survive (only attachments went)")
  361. check(count("select count(*) from capabilities where name = 'doomed-cap'") == 1,
  362. "the capability DEFINITIONS survive (only requirements went)")
  363. check(count("select count(*) from stacks where name = 'doomed-stack'") == 1,
  364. "the stack itself survives (only its membership row went)")
  365. check(count("select count(*) from saddles where name = 'doomed-saddle'") == 1,
  366. "the saddle registration survives")
  367. print("== the page after the delete ==")
  368. # The detail page of a deleted project reports not-found.
  369. state = slot_public(auth, "/projects/%d" % doomed, "project")
  370. check(state["exists"] is False,
  371. "the deleted project's page reports not found")
  372. names = [p["name"] for p in slot_public(auth, "/projects", "projects")["projects"]]
  373. check("doomed" not in names and "keeper" in names,
  374. "the projects list shows the survivors only")
  375. print("== the UI path: the action deletes and redirects ==")
  376. # End-to-end through the page action (the dialog's form): a fresh
  377. # project with one goal and one task goes, the reply navigates to
  378. # /projects with a success notify.
  379. ui_doomed = make_project("ui-doomed")
  380. sql_exec(
  381. ("insert into tasks (project_id, goal_id, kind, mode, creator, title,"
  382. " description, status, priority, created_at)"
  383. " values (?, 0, 'task', 'implement', 'human', 'UI task', 'x',"
  384. " 'done', 5, strftime('%s','now'))", (ui_doomed,)),
  385. ("insert into goals (project_id, title, brief, branch_name, status,"
  386. " creator, created_at)"
  387. " values (?, 'UI goal', 'b', 'goal/ui', 'complete', 'human',"
  388. " strftime('%s','now'))", (ui_doomed,)),
  389. )
  390. ui_state = slot_public(auth, "/projects/%d" % ui_doomed, "project")
  391. check(ui_state["delete_counts"] == "1 goal, 1 task",
  392. "the UI project's counts read '1 goal, 1 task' ('%s')"
  393. % ui_state["delete_counts"])
  394. result = act(auth, ui_state["delete"],
  395. {"project_id": str(ui_doomed), "confirm_name": "ui-doomed"})
  396. notify = action_notify(result) or ""
  397. check("deleted" in notify and "untouched" in notify,
  398. "the action reports the delete and the survival guarantee ('%s')" % notify)
  399. check(any(d.get("type") == "navigate" and d.get("uri") == "/projects"
  400. for d in result), "the action redirects to the projects list")
  401. check(count("select count(*) from projects where id = ?", (ui_doomed,)) == 0
  402. and count("select count(*) from tasks where project_id = ?", (ui_doomed,)) == 0
  403. and count("select count(*) from goals where project_id = ?", (ui_doomed,)) == 0,
  404. "the UI delete took the project with its task and goal")
  405. # Idempotence of the wire: deleting the same project again is a 404.
  406. status, body = delete_api(auth, doomed)
  407. check(status == 404, "deleting an already-deleted project answers 404")
  408. # Housekeeping: the leftover helper rows this bundle created.
  409. sql_exec(
  410. ("delete from goal_links where goal_id in (select id from goals where"
  411. " project_id in (?, ?))", (keeper, standalone_host)),
  412. ("delete from goals where project_id in (?, ?)", (keeper, standalone_host)),
  413. ("delete from tasks where id = ?", (standalone_task,)),
  414. ("delete from tasks where project_id = ?", (other,)),
  415. ("delete from goal_links where goal_id = ?", (other_goal,)),
  416. ("delete from goals where id = ?", (other_goal,)),
  417. ("delete from project_links where parent_project_id in (?, ?)",
  418. (other, keeper)),
  419. ("delete from stack_projects where stack_id ="
  420. " (select id from stacks where name = 'doomed-stack')",),
  421. ("delete from stacks where name = 'doomed-stack'",),
  422. ("delete from rules where name = 'doomed-rule'",),
  423. ("delete from capabilities where name = 'doomed-cap'",),
  424. ("delete from saddles where name = 'doomed-saddle'",),
  425. ("delete from projects where id in (?, ?, ?)", (other, keeper,
  426. standalone_host)),
  427. )