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<...>incrates/babylon-core/src/ops/issues.rs. All fields afteridare optional; at least one must beSomeor it returnsError::TooLarge("update needs at least one field"). Authz is shared withresolveviaHub::assert_can_resolve(extracted intoops/lifecycle.rs). Writes go through private setter helpers, each wrapping onestore.with_writer(...)txn. Pairs with the existingresolveauthz/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:
| Field | Effect |
|---|---|
status:"closed" | Calls self.resolve(...) — sets resolved_at / resolved_by |
status:"open"/"in_progress"/"blocked" | assert_can_resolve → clear_resolved (NULLs resolved_at and resolved_by) → set_issue_status |
assignee | reassign_issue (replace, not append) → wake the new assignee’s long-poll |
parent | assert_no_cycle → set_issue_parent |
title / body | set_message_field |
all None | Error::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'.
resolvenow callsassert_can_resolveafter its idempotent early-return.update_issue’s close path reusesself.resolve(gets the authz for free).update_issue’s reopen path reusesassert_can_resolvedirectly.
No authz logic is duplicated across the three call sites.
Status semantics — reopen re-checks authz AND clears resolution
status:"closed"→self.resolve(...)(idempotent; setsresolved_at/resolved_by).- Any non-closed status first calls
assert_can_resolve, thenclear_resolved(NULLs bothresolved_atandresolved_by), thenset_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:
DELETEall rows frommessage_mentionsfor the message.INSERTthe single new assignee.- Upsert a
subscriptionsrow:ON CONFLICT(handle, channel_id) DO UPDATE SET active=1, withlast_acked_id = msg_id - 1so 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 returnformat!. Bothclippy::used_underscore_bindingandclippy::uninlined_format_argsfired. The fix was to rename_num→numand 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 ofresolve, its now-orphanedauthorbinding became unused and had to be renamed_authorto keep the crate warning-clean.
Known wart (noted, not fixed)
assert_can_resolvere-queries the message row (kind / ckind / author), duplicating the kind + DM-can_seechecks thatresolvealready 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 existingresolve_authz_idempotent_and_kind_checkedtest 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 throughstore.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 singlewith_writertransaction.
Tests
4 new tests added to the ops::issues mod tests:
close_sets_resolved_then_reopen_clearsclose_authz_rejects_outsiderreassign_replaces_mentions_and_reparent_blocks_cycleupdate_with_no_fields_errors
Full babylon-core suite: 54 passing. cargo clippy -p babylon-core --all-targets clean.
Related
- babylon — project overview /
babylon-coreis the engine crate - babylon-design-decisions — single-writer + wake-the-recipient + typed messages this builds on
- Code audit + hardening (2026-06-09) — the pre-existing
resolveauthz/idempotency contract thatassert_can_resolvefactors out