Task 4 of the babylon issue-tracker feature: a single Hub::update_issue entrypoint in crates/babylon-core/src/ops/issues.rs that handles every mutation of an existing issue — status changes (close/reopen), reassignment, reparenting, and title/body edits — reusing the existing resolve/lifecycle authorization instead of duplicating it. Shipped at pin cbc9233; full babylon-core suite 54 passing, clippy-clean.

For Agents

Entrypoint: Hub::update_issue(&self, by: &Handle, id: i64, status, assignee, parent, title, body) -> Result<...> in crates/babylon-core/src/ops/issues.rs. All fields after id are optional; at least one must be Some or it returns Error::TooLarge("update needs at least one field"). Authz is shared with resolve via Hub::assert_can_resolve (extracted into ops/lifecycle.rs). Writes go through private setter helpers, each wrapping one store.with_writer(...) txn. Pairs with the existing resolve authz/idempotency (see Code audit + hardening (2026-06-09)).

What it does

update_issue is the one method the MCP layer calls for any edit to an issue. It dispatches on which optional fields are present:

FieldEffect
status:"closed"Calls self.resolve(...) — sets resolved_at / resolved_by
status:"open"/"in_progress"/"blocked"assert_can_resolveclear_resolved (NULLs resolved_at and resolved_by) → set_issue_status
assigneereassign_issue (replace, not append) → wake the new assignee’s long-poll
parentassert_no_cycleset_issue_parent
title / bodyset_message_field
all NoneError::TooLarge("update needs at least one field")

Key design decisions

Authz extraction (DRY) — assert_can_resolve

Pulled pub(crate) async fn assert_can_resolve(&self, by: &Handle, id: i64) -> Result<()> out of the existing Hub::resolve in crates/babylon-core/src/ops/lifecycle.rs. It holds the author/assignee/operator block:

A message is resolvable by its author, by anyone mentioned/assigned on it, or by any agent with kind='operator'.

  • resolve now calls assert_can_resolve after its idempotent early-return.
  • update_issue’s close path reuses self.resolve (gets the authz for free).
  • update_issue’s reopen path reuses assert_can_resolve directly.

No authz logic is duplicated across the three call sites.

Status semantics — reopen re-checks authz AND clears resolution

  • status:"closed"self.resolve(...) (idempotent; sets resolved_at / resolved_by).
  • Any non-closed status first calls assert_can_resolve, then clear_resolved (NULLs both resolved_at and resolved_by), then set_issue_status.

So reopening is not a bare status flip: it re-verifies the caller is authorized and wipes the resolution timestamp + resolver in the same operation.

Reassign replaces, not appends

reassign_issue:

  1. DELETE all rows from message_mentions for the message.
  2. INSERT the single new assignee.
  3. Upsert a subscriptions row: ON CONFLICT(handle, channel_id) DO UPDATE SET active=1, with last_acked_id = msg_id - 1 so the new assignee sees the issue on next catch-up.

After the write, update_issue calls self.waiters.wake(&assignee) to wake any active long-poll for that handle (same wake-the-recipient pattern as the rest of the hub — see babylon-design-decisions).

Cycle guard on reparent — assert_no_cycle

assert_no_cycle walks the parent chain upward from the proposed new parent via SELECT parent_id FROM issues WHERE message_id=?. If the walk reaches the issue being reparented, it returns Error::IssueCycle. This prevents an issue from becoming its own ancestor.

Empty update rejected

If status, assignee, parent, title, and body are all None, it returns Error::TooLarge("update needs at least one field") rather than silently no-op’ing.

Gotchas

Clippy on underscore-prefixed bindings

The task brief’s verbatim code bound _num (underscore prefix = “intentionally unused”) but then used it in the return format!. Both clippy::used_underscore_binding and clippy::uninlined_format_args fired. The fix was to rename _numnum and inline it: format!("#{prefix}-{num}").

Lesson: an underscore prefix is a promise the binding is unused — if you later use it, drop the underscore; do not reach for #[allow]. Conversely, after moving the authz block out of resolve, its now-orphaned author binding became unused and had to be renamed _author to keep the crate warning-clean.

Known wart (noted, not fixed)

assert_can_resolve re-queries the message row (kind / ckind / author), duplicating the kind + DM-can_see checks that resolve already does in its preamble before calling it — one redundant read on the resolve path. It’s kept self-contained on purpose so the reopen path can call it standalone. Behavior-preserving; the existing resolve_authz_idempotent_and_kind_checked test confirms no regression.

Project conventions reinforced

babylon-core house rules (apply to all ops/ work)

  • Rust edition 2024, rustc 1.88.
  • NO comments anywhere — hard rule, no docstrings or inline annotations.
  • Timestamps are epoch milliseconds.
  • All writes go through store.with_writer(move |c| Box::pin(async move { ... })); all reads through store.reader().
  • Crate must stay clippy-warning-clean: cargo clippy -p babylon-core --all-targets = 0 warnings.
  • Strict TDD: write failing tests → confirm RED via cargo test -p babylon-core ops::issues → implement → GREEN.
  • The setter helpers (set_issue_status, clear_resolved, reassign_issue, set_issue_parent, set_message_field) are private, each wrapping a single with_writer transaction.

Tests

4 new tests added to the ops::issues mod tests:

  • close_sets_resolved_then_reopen_clears
  • close_authz_rejects_outsider
  • reassign_replaces_mentions_and_reparent_blocks_cycle
  • update_with_no_fields_errors

Full babylon-core suite: 54 passing. cargo clippy -p babylon-core --all-targets clean.