connect: the operator's boost on a declared ask is the go
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
discardand opens the ledger the same way: it works beside a running connector, and refuses when the ledger needs migrating under one.
- Nothing existing carried this. The outbox records only the connector's own lifecycle messages (
- Only the operator's boost is a go, in every trust mode. Admission sets
Verdict.AskRecordingIDonly on an admittedboostedverdict whose performer isTrust.OperatorIDand which was not delegated. That is the same spirit asOperatorOnly, but stricter:AllowAssignments, the allowlist and project trust never widen it. A boost on an ask from anyone else is an ordinaryboostedreply, and leaves the ask open for the operator. - One ask gets one go. The ledger takes the go in the verdict's own transaction (
takeGoinAdmission.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_idnames 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.
- A second boost from the operator is a reply,
- 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, andrecordingis the ask. The handoff also checks that the trigger isboosted, 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
boostedrequest withgo: 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. ownerstays 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.AskFingerprintis 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.
- A titled post (message, card, to-do, document) hashes its title and its content, each length-prefixed (
- Same read at both ends:
connect asktakes 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
boostedreply withgo: 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: truerequest carriesask: {title, content}: the exact words admission read and the ledger matched. The skill acts only on that text, never on a fresh read ofrecording, 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 asktakes 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
askstable; 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
boostedrule, 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):AskRecordingIDis 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_runstays taken, and the next boost is a reply
-
Handoff: the line says
go: trueonly for the go and is false for the second boost and for a mention. A non-boosted record never says go. -
CLI:
connect askreads and fingerprints the comment a#__recording_NURL names, not its parent. A second read of the unedited post gives the same hash. An edited post makes a re-declaration reporteditedand 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 givesgo: true; an edited one givesgo: falseand 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/citargets 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 lintstops on the darwin-onlyinternal/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.
- repo-wide