connect: the operator's boost on a declared ask is the go

JE
1d ago
+1086-25

Stacked on #877 (connect-boosted). This PR's base is that branch, so the diff is only the follow-up. #877 is unchanged: there, a boost is a reply.

Problem

In #877 a boost on the agent's own post arrives as a boosted reply, and the skill says it approves nothing. An agent asking the operator for a go still has to wait for an @mention. The operator's decision: a boost counts as the go, but only on an explicit ask.

Card: https://app.basecamp.com/2914079/buckets/46292715/card_tables/cards/10396224165

Change

  • Declaring an ask is structural, never textual. The agent declares an ask when it posts one: basecamp connect ask <app_url> -P <profile>. It reads the post once, as the agent, through the same summary read admission uses, and refuses a post the agent didn't write or that isn't published. It then writes the recording id and a hash of the post's content into a new ledger table, asks (migration 15). Nothing reads a post's words to decide it is an ask.
    • Nothing existing carried this. The outbox records only the connector's own lifecycle messages (guard_ack, holding_reply, still_running, completion, retraction). In handoff mode the session's replies go through the CLI, not the outbox, and the events ledger holds only what the feed delivered. So a small table is the least new state that can say "this post is an ask".
    • The command sits beside discard and opens the ledger the same way: it works beside a running connector, and refuses when the ledger needs migrating under one.
  • Only the operator's boost is a go, in every trust mode. Admission sets Verdict.AskRecordingID only on an admitted boosted verdict whose performer is Trust.OperatorID and which was not delegated. That is the same spirit as OperatorOnly, but stricter: AllowAssignments, the allowlist and project trust never widen it. A boost on an ask from anyone else is an ordinary boosted reply, and leaves the ask open for the operator.
  • One ask gets one go. The ledger takes the go in the verdict's own transaction (takeGo in Admission.commit): UPDATE asks SET go_event_id = ? WHERE recording_id = ? AND (go_event_id IS NULL OR go_event_id = ?). Two boosts commit in order, and only the first finds the ask open.
    • A second boost from the operator is a reply, go: false.
    • So is a boost removed and given again. Basecamp records that as a new boost and a new event.
    • Idempotent: the event that took the go keeps it if it is decided again (a redispatch), because go_event_id names that event.
    • A go taken stays taken, even when its record is closed unwritten (before_this_run). On restart, that looks the same as a line printed just before a crash, and giving the go back would let the next boost approve twice. So a boost given while no connector runs uses the ask up, and the skill tells the agent to ask for the go by mention. Losing a go costs the operator a mention; a second go costs a second action. (An earlier head released the go here; Copilot showed the double approval, and the release is removed.)
    • Declaring an ask again doesn't reopen one whose go was taken. Declaring one after the boost was decided isn't retroactive, so the skill has the agent declare right after posting, before anything else.
  • The session can tell a go from a thumbs-up. The request line gains "go" (boolean, always present, in the contract and the skill's example). It is true only on the boosted request that took the go, and recording is the ask. The handoff also checks that the trigger is boosted, so no other line can say go.
  • Skill.
    • The agent declares a post an ask right after posting it, and the post tells the operator that a boost on it is a go too. It declares only a post that asks for a go and names exactly what it approves, one ask per post.
    • An operator's go now includes a boosted request with go: true. The go approves what the ask names, and only that. It is never a person's word on a merge, a deploy or anything else the ask didn't name, and never a change to the connector, which only the owner's own mention makes.
    • owner stays false for a boost.

An edited ask gives no go, and a go approves the words as read

These are the operator's choices on the edited-ask threads.

  • Hash: admission.AskFingerprint is SHA-256 of the ask's words exactly as the summary read returns them; nothing is normalized and no HTML is canonicalized.
    • A titled post (message, card, to-do, document) hashes its title and its content, each length-prefixed (<len>:<title><len>:<content>) so the two fields can't run together.
    • A comment or chat line hashes its content alone, because its title is its parent's and a card rename would change it.
  • Same read at both ends: connect ask takes the fingerprint when it declares the ask. Admission takes it again from its own read of the boosted recording. I chose reading the post at declaration over taking the content the agent passes in, because both sides then hash the same fields from the same read.
  • No-op reads match: two reads of an unedited post fingerprint the same. This is tested, and checked live against production on a card.
  • On an edit: the ledger gives the go only when the two fingerprints match. Otherwise the boost is an ordinary boosted reply with go: false, and the ask isn't used up.
  • Re-declaring: keeps the first fingerprint and reports edited, so an edited ask stays void and the agent posts a new one.
  • The worker acts on the read copy. A go: true request carries ask: {title, content}: the exact words admission read and the ledger matched. The skill acts only on that text, never on a fresh read of recording, which stays for replying. An edit after admission therefore can't change what a go approves.
    • A go whose record carries no such words (one admitted before this build) isn't handed off as a go.
  • Declined: edit, then restore before admission. Only the agent, the operator it assists, or (for a titled post) an account admin can edit an ask, and each already holds a more direct route. The reasoning is in https://github.com/basecamp/basecamp-cli/pull/879#issuecomment-6139936948.
  • Bare ids: connect ask takes the post's URL, not a bare id, because the read routes by the type the URL names.

Asks don't expire

The go is the operator's deliberate boost on the post itself. What it approves is exactly what the post names, and the skill already has the worker check that this is still current before acting: the branch still at the commit the ask named, an ask already answered as done no longer waiting. A deadline would add a number with nothing to choose it by, and would silently turn a late but deliberate approval into a thumbs-up.

Not done

  • No new state beyond the asks table; no text matching.
  • No network check that the declared recording is the agent's own. This isn't needed: a go also requires the boost to pass #877's boosted rule, which admits only boosts on recordings the agent created. A declared ask on someone else's post can never fire.
  • Nothing in code limits what a go may approve. That limit lives in the skill, as the operator asked.

Tests

  • Admission (TestOnlyTheOperatorsBoostMayBeAGo): AskRecordingID is set for the operator's boost only. It is not set for an allowlisted operator who is opted in to assigning, a project member, the operator's mention, or a boost on someone else's post.

  • Ledger (ledger_asks_test.go):

    • one go per ask: second and re-given boosts are not go, and re-declaring doesn't reopen
    • the same event keeps its go
    • an undeclared post gives no go, and declaring after the boost isn't retroactive
    • a non-operator boost leaves the ask open for the operator's
    • a go closed before_this_run stays taken, and the next boost is a reply
  • Handoff: the line says go: true only for the go and is false for the second boost and for a mention. A non-boosted record never says go.

  • CLI: connect ask reads and fingerprints the comment a #__recording_N URL names, not its parent. A second read of the unedited post gives the same hash. An edited post makes a re-declaration report edited and keeps the first hash. Someone else's post, a bare id, another account, an untrusted host, a listing, or a non-post type is refused and declares nothing.

  • End to end (TestAGoIsOnlyForTheAskAsDeclared, real admitter, ledger and handoff): an unedited ask gives go: true; an edited one gives go: false and the ask stays open. Removing the hash comparison, or hashing anything but the read content, fails a test.

  • Mutation-checked: dropping the operator check, the one-go condition or the boosted-only check in the handoff each fails a test.

  • Gate: thelio was unreachable (it needs a Tailscale re-auth), so I ran the bin/ci targets locally on macOS. fmt, vet, lint on the changed packages, e2e, sync-skills, surface, skill drift and the rest all pass. Not covered locally:

    • repo-wide make lint stops on the darwin-only internal/connector/driver/proctime_darwin.go, which this PR doesn't touch;
    • TTY-dependent unit tests fail here because the run has no terminal, and they fail the same way on the base branch.

    GitHub CI is the gate for both.