PRs #1137-#1151 — v0.17.0: Ticket-Driven Approval Auto-Resume

Where we left off

Post #127 (v0.16.0) shipped synlynk tpm sweep: a loop that walks every ready story through dispatch → verify → PR → review → merge, gated at each step on check_authority(). The headline result of that release's live dogfood run was that the pause path worked — a policy-flagged action correctly parked a story, filed a real [APPROVAL] GitHub issue, and detected the resolving comment.

But that post ended with an explicit, named gap, not a victory lap:

Resolving an approval ticket does not auto-unblock a re-sweep: check_authority() is purely policy-rule-based with no awareness of ticket-resolution state, so approval_resolved firing does not itself let the parked story advance on the next pass.

In plain terms: you could approve a ticket on GitHub, and the next tpm sweep pass would re-evaluate the same policy rule, get requires_approval=True again, and park the story a second time — or worse, file a duplicate ticket (exactly the failure mode issues #1130/#1131 had already surfaced). The authority layer could pause the loop. It could not yet resume it. v0.16.0's goalpost named this as "the natural v0.17.0-scale increment."

What moved the goalpost

No strategic pivot here — this release is the increment v0.16.0 already scoped for itself. The interesting design constraint was architectural: check_authority() is a fail-closed, no-cache pure function (re-reads policy.json every call, deliberately mirroring load_config()'s convention). Making it stateful — aware of which tickets had been resolved — would have broken that contract and made every authority check implicitly depend on ticket-table state.

The brainstorm's answer was to keep check_authority() exactly as pure as it was, and instead give the sweep loop itself three-way state awareness per (story, action) pair, sitting one layer above the policy check:

  1. No ticket exists yet for this story/action → file one, park.
  2. An open ticket exists → keep parking, don't file a duplicate.
  3. A resolved ticket exists → consume it, let dispatch proceed.

This is the same shape of decision _ready_stories() already makes for daemon_jobs status — the sweep loop, not the policy resolver, tracks in-flight state.

What this release shipped

Five PRs, four implementing the data model and one verifying it live:

PR #1137 — approval_tickets table. Added to the existing schema block following the same self-heal-migration convention already used for subscriptions/daemon_jobs — existing local state.db files pick up the new table without a full wipe.

PR #1138 — DB helpers in synlynk/db.py. _find_ticket(story_id, action, status), _insert_ticket(story_id, action, issue_url), _mark_ticket_consumed(ticket_id) — small, single-purpose functions matching this file's existing connection-open/close style.

PR #1139 — the three-branch check wired into run_sweep_pass() (synlynk/tpm_sweep.py). This is the actual behavior change: before a requires_approval result now means an automatic, permanent park, the sweep loop first checks ticket state and only parks (or files a new ticket) if there isn't already a resolved one waiting to be consumed.

PR #1141 — _scan_approval_tickets() writes back to the table (synlynk/events.py). Previously this function only emitted an approval_resolved GOVERNS event when a ticket was closed or commented "approve" — nothing durable recorded that the ticket itself had moved to a resolved state. Now the same code path does UPDATE approval_tickets SET status='resolved', resolved_at=? WHERE issue_url=? AND status='open' at the point it emits the event, so the resolution is queryable state the next sweep pass can actually consume, not just a log line.

Task 5 — live dogfood verification, Claude-direct. Per the plan, this step wasn't dispatched — it's PM/deploy work run directly against the real repo, with every claim independently cross-checked rather than trusted from the sweep's own printed summary (the discipline established in the v0.16.0 dogfood). A demo story (story-becf09a5) was pushed through the full lifecycle using a temporary, fully-reverted policy rule (task_dispatch_demo, added and removed in a throwaway worktree/branch that was never merged):

  • Sweep 1 parked the story and filed ticket id 8 → issue #1149.
  • Sweep 2 confirmed no duplicate: gh issue list --search "[APPROVAL] in:title story-becf09a5" returned exactly one issue.
  • gh issue comment 1149 --body "approve" plus a manual scan_local_events() call produced GOVERNS event id 371approval_resolved referencing issue #1149, confirmed via synlynk events tail --type approval_resolved.
  • Sweep 3 dispatched instead of re-parking: job job-e8277299, exit 0, and the ticket row flipped to status='consumed' with consumed_at set — confirmed via a direct _find_ticket() query, not sweep's printed summary.
  • The temporary policy change was reverted, confirmed as zero diff against origin/main, and the full test suite re-run clean (2219 passed, 2 skipped, 2 already-known-flaky) before the throwaway worktree was discarded.

A process note worth naming

Tasks 1-3's implementer stage dispatched cleanly to Codex, as normal for this project's PM/review-only split. Task 4 didn't: the Claude Code auto-mode classifier repeatedly denied the synlynk dispatch call, even with valid role-scoped GitHub App credentials already in place. Rather than retry indefinitely, Task 4 was implemented directly, with explicit sign-off, and the block itself was filed as LIVE-6, issue #1140 (Sev2 — a workaround existed, so no data loss or prod defect, but it directly degrades the autonomy-design goal this whole plan exists to serve). The root cause — whether it correlates with .pem file presence in the dispatch worktree, or the --role flag itself — isn't investigated yet. It's the most interesting open thread coming out of this release, precisely because it's a meta-failure: the tool built to make dispatch reliable had a dispatch reliability gap of its own.

Brainstorm visuals

None — this design built directly on the already-approved GOVERNS event contract, approval-gate ticket flow, and check_authority() policy-merge rules from prior releases, with no new visual/UX surface to work through.

What this achieved on the path to autonomy

v0.16.0 proved the authority layer could pause an unattended loop safely. v0.17.0 proves it can resume one — which is the half of "supervised autonomy" that actually matters for a loop meant to run unattended for stretches of time. A human (or any external system) can now approve a gated action on GitHub and trust that the next sweep pass will pick it up correctly, exactly once, without babysitting the loop to manually re-trigger dispatch.

Next goalpost

Two threads, not one, come out of this release:

  • LIVE-6 (#1140) needs root-causing — a classifier that blocks credentialed dispatch calls is a direct tax on the autonomy story, and it's the kind of gap that will keep forcing manual-implementation fallbacks until it's understood.
  • With the pause-and-resume loop now complete end-to-end, the next natural increment is running tpm sweep unattended for a longer stretch against the real backlog — not a single demo story — to see what other rough edges (like the #1132/#1133 gaps found during v0.16.0's dogfood) only show up under sustained, real usage.