Help us shape the future of Go! Take the 2026 Go Developer Survey

admission

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package admission turns a seen account-feed event into a verdict: admitted, queued, blocked or discarded. It is step 15 of the connector plan, and it sits between intake (which writes a pointer to the ledger and hands its id to a queue) and dispatch (which starts a worker for an admitted record).

Order of work, cheapest first

  1. The gate (Gate) is pure code over the pointer and the local policy: the event type is in the trigger matrix, the performer is trusted for at least one of the type's triggers, the bucket is in scope, and a trigger that only a served project admits is in one. Most account traffic ends here, for the price of its pointer.
  2. A chat line in a bucket connect.json does not serve is read as a Ping (Reads.Pings), because where the line was said decides whose word counts, and that comes before anyone's trust is read. A bucket that is no Ping goes on as an unserved project's; its answer is cached.
  3. In project trust mode a performer the policy does not name is confirmed as a non-client member of the project. That is a read, cached per project, and it is the one trust decision the pointer alone cannot make.
  4. The RecordingSummary read (the SDK's step-10 helper) resolves the recording through the typed read its event type names. A boost's type names no recording, so a boost is read first, for the boosted recording's type and for the boost itself. A recording still drafted or since trashed is stale.
  5. The trigger rules decide what the event is to the agent: mentioned (the agent's Person id is among the mention attachments — never a name), subscribed (a subscription read on the commented recording), assigned (the recording's events, at most five pages, for details.added_person_ids) completed (trusted completer and a stake: the served project's watch_completions flag, an assignment, or a subscription), or boosted (a boost on the recording itself, which the agent wrote, given by the trusted performer; a boost on one of a recording's events is not), or, in a Ping, pinged (an operator's line in a direct Ping, the agent and its writer alone; in a group Ping, and from a participant anywhere, only a mention counts).
  6. Whether the project is served, its class, the reply destination, the conversation key and the content snapshot come from the policy and the summary, never from the pointer.

Trust

Trust is keyed on Person id. The agent's own id never authorizes: an event the agent created, an event the agent performed, an event carrying an agent actor type, and an event performed on someone's behalf by any agent are all discarded before anything else is considered, so an agent mentioning an agent stops at zero hops — unless the operator names that agent in agent_ids (--allow-agent). An allowed agent is let through by one rule only, mentioned, as a participant, when it wrote the content itself; and the committer caps those mentions per conversation and per agent over a rolling window (agent_thread_cap, agent_daily_cap), which is what bounds two agents that allow each other. Assignments are operator-only in every trust mode, unless allow_assignments opts in the people the allowlist names; the assigner is the feed's performer, which Basecamp writes, so naming someone trusts that person and not a payload that claims to be them. For the triggers that carry an instruction in content (mentioned, subscribed) the recording's author must be trusted too, because the performer of a *.created event is not always the person who wrote it — a to-do moved in from another project is created by the mover.

Trust has two roles. Operators are the operator and the people the allowlist names, in allowlist or project mode; their word authorizes the agent. Participants are the members project mode admits besides them: they reach the agent by mention, by comment on a thread it follows, by a completion it has a stake in, and by boosting something it wrote, never by assignment. A boost's content is the boost, which its booster wrote, so the booster is held to the performer's trust and there is no other author. The operator's boost alone, in every trust mode, also names the boosted post as the ask it may be the go for (Verdict.AskRecordingID); whether the agent declared that post an ask, and whether its go is still open, is the ledger's to decide in the verdict's transaction. Every admitted verdict carries its Role, the lesser of the performer's and, for mentioned and subscribed, the author's, so words a participant wrote stay a participant's request whoever brought them in. Admission only labels; what a participant may get the agent to do is the policy of whoever reads the request.

A Ping is no project, so project trust has no members there: an operator gives the agent work in any Ping, in every trust mode, in words an operator wrote. The one participant a Ping has is a person connect.json lets into that Ping alone (Policy.Pings, --allow-in-ping): their mention there is a participant's request, in every trust mode, and anywhere else they are whoever the rest of the policy makes them. The entry counts only once the Ping read says its bucket is a Ping, never in a served project, and never for an agent, which Validate refuses. A Ping needs no serving; its conversation is the Ping (PingConversationKey), answered in its one Campfire.

Invariants

Every rule below is held by a test that fails without it.

  1. No agent authorizes. The agent itself is refused as the event's creator or performer and as the author of the recording carrying the instruction. Any other agent is refused as a delegate, as the push lane's actor type, as a project member, and as that author. One case the pointer cannot show: on the poll lane, an Agent person the operator put in the allowlist, performing an event whose content it did not write (a completion). Allowlisting an agent is an operator error that Validate cannot detect, and setup refuses. The one exception is an agent named in agent_ids: its own mention, admitted as a participant's request and never an operator's, held to the agent caps, and never in a Ping, where no agent may be a participant. With no agent named, the feed's actor_types=person filter excludes agents at source, as before.
  2. An assignment is admitted only when the operator performed it (or a person the allowlist names, under allow_assignments), the recording's events show this event added the agent, and the agent is still assigned. Missing, unfound or partial assignment data blocks as delta_unverified; it never admits and never discards.
  3. Discarded means verified. A discard rests on the pointer and the policy, or on a read that answered with what the verdict needs. A negative subscription answer is always read fresh, and a membership refusal comes only from a listing read after the event was seen; otherwise the record is held as trust_unverified. A read that failed, was refused, or answered without an author, a parent or a Campfire blocks; blocked records are retained and recovered.
  4. Only an admitted verdict carries the recording's content. A blocked or discarded verdict carries the pointer, the reason, and for a blocked one what its holding reply needs. Nothing written to stdout carries content.
  5. One verdict per event, chosen and written in one ledger transaction. The ledger refuses a verdict whose record changed after the decision loaded it, and decides admitted or queued against the conversation as it stands in that transaction. The committer reports what the ledger wrote. A decision the context interrupted is not written.

The seam with intake

Intake (internal/connector) hands admission an event id through its Queue; the record behind that id is the ledger row, whose pointer fields are exactly Event's. Run is written against two small interfaces, IDSource (satisfied by that Queue's Take) and Records (a ledger read returning a seen record as an Event), plus Ledger, whose Commit contract is invariant 5. Intake's ledger implements Records and Ledger (connector.Ledger.Admission), and connector.RunAdmission wires Run onto the queue; this package imports nothing from that one.

What is not here

Tasks, attempts, the outbox, acknowledgements, and the holding reply a blocked(no_route) record receives are dispatch and lifecycle (plan steps 17 to 20). The queued trigger is the rest of step 25, whose boosted trigger is built, and edited is step 28: each is a row added to the Matrix plus its rule, and the gate does not change.

Index

Constants

View Source
const (
	BlockedRetryInterval = 10 * time.Minute
	BlockedRetryWindow   = 24 * time.Hour
)

Blocked-record recovery. A record blocked on a read (read_failed, read_unresolved), on unverified trust (trust_unverified) or on an unverified assignment delta is retried every ten minutes for a day after it was first blocked, and on redispatch at any time after that. A throttled record (throttled) is on the same schedule, never before the server's deadline. A record blocked because connect.json could not be read (config_unreadable) is on the same interval with no window at all: that is a local file a person repairs, and an operator away for a week is ordinary, where a day of a failing server means the server is not coming back on its own. bucket_mismatch and unroutable are not timed: the pointer's bucket and type never change, so only a person's redispatch re-runs them. no_route is not timed either — it waits for the operator to serve the project, which is a decision, not a delay. A blocked record is never discarded for having failed: the checkpoint may already be past the event, and a tombstone would turn an outage into a permanent loss.

The intake sweep is what runs this schedule (internal/connector, Intake.sweepBlockedRetries): it asks the ledger for the records this function calls due and offers them back to admission.

View Source
const (
	DefaultAgentThreadCap = 3
	DefaultAgentDailyCap  = 20
	// MaxAgentCap bounds either cap, so a cap is never a way to switch the
	// protection off.
	MaxAgentCap    = 100
	AgentCapWindow = 24 * time.Hour
)

Agent-to-agent loop bounds. Two agents that allow each other can answer each other's mentions indefinitely; these caps are what stops that. They count admitted agent requests in the ledger over a rolling window.

View Source
const (
	// SubscriptionTTL is how long a subscription answer is reused.
	SubscriptionTTL = 10 * time.Minute
	// MembershipTTL is how long a project's member list is reused.
	MembershipTTL = 10 * time.Minute
	// NotAPingTTL is how long a bucket's answer that it is no Ping the agent
	// is in is reused. Only that answer is cached: a project never becomes a
	// Ping, while a Ping's people are read fresh each time they decide.
	NotAPingTTL = 10 * time.Minute
	// MaxAssignmentPages bounds the events read for an assignment delta.
	MaxAssignmentPages = 5
)

Read bounds.

View Source
const (
	DefaultReadAttempts = 5
	DefaultReadBackoff  = 2 * time.Second
)

Read retry defaults. A read that fails is retried with backoff and, after DefaultReadAttempts failures, the record is blocked(read_failed) — retained and recovered on the blocked schedule, never discarded.

View Source
const ActorTypeAgent = "agent"

ActorTypeAgent is the feed's actor type for an Agent person. Only the push lane carries actor_type; a poll row leaves it empty.

View Source
const DefaultWorkers = 4

DefaultWorkers is the admission fetcher pool size.

View Source
const MembershipRefreshFloor = 30 * time.Second

MembershipRefreshFloor bounds how often a project's listing is read.

View Source
const PingConversationPrefix = "ping:"

PingConversationPrefix is PingConversationKey's prefix, for a query that builds the key from a bucket id.

Variables

View Source
var ErrAlreadyDecided = errors.New("admission: event already decided")

ErrAlreadyDecided is the ledger's answer to a verdict whose record changed after the decision loaded it: another decision was written first.

View Source
var ErrMembershipUnverified = errors.New("admission: membership not verified against a listing as recent as the event")

ErrMembershipUnverified reports a refusal the reader could not verify against a listing as recent as the event.

Functions

func AskFingerprint added in v0.14.0

func AskFingerprint(recordingType, title, content string) string

AskFingerprint is what an ask is declared with and checked against: the SHA-256, hex-encoded, of the words the ask is made of, exactly as the summary read returns them. Both ends read the same fields through the same read (basecamp.RecordingsService.Summarize), so an unedited post fingerprints the same, and any change at all — an edit's, or Basecamp's own — voids the go rather than approving words the operator may not have seen. Nothing is normalized: no whitespace folded, no HTML canonicalized.

A post with a title of its own — a message's subject, a card's or to-do's title, a document's — is approved by its title as much as its content, so both are hashed, each prefixed with its length in bytes and a colon ("<len>:<title><len>:<content>"), so no split of one string into the two fields reads as another. A comment, a chat line and a check-in answer have no title of their own — a comment's title is its parent's, an answer's is its question's, and someone else's rename would change either — so their content alone is hashed, bare.

func AskTitle added in v0.14.0

func AskTitle(recordingType, title string) string

AskTitle is the title an ask is approved by: its own, or none for a comment, a chat line or a check-in answer.

func CheckCanonicalKeys

func CheckCanonicalKeys(data []byte) error

CheckCanonicalKeys walks a connect.json document and refuses any object that names a key twice or spells one noncanonically. Every reader of the file runs it, and it is exported so there is one implementation rather than one per reader: the whole point is that no two readers of the trust anchor can disagree about what a document says.

The reason is encoding/json's field matching. It matches a struct tag case-insensitively and parses an integer map key with strconv, so to the decoder "Trust" is "trust", "PATH" is "path", and "01" is 1. A validation keyed on the canonical spelling therefore looks for a key the decoder has already matched under another one, and a value lands in a field nothing checked. Refusing the noncanonical spelling up front is what makes an exact comparison anywhere downstream a complete one.

What that was worth here. Copilot on #765 reported one of these; the other five came out of looking at the seam the two readers share rather than at the field it named:

{"Trust":{"mode":"allowlist","allowlist_ids":[…]}}  trust mode escalated
{"Projects":{"2":{}}}                               a second served project merged in
{"projects":{"01":{}}}                              a served project setup refuses
{"projects":{"1":{"Path":null}}}                    the legacy-path check skipped
{"projects":{"1":{"PATH":"../x"}}}                  likewise, with a relative path

The reported one is the fourth. Each of the others is the same mechanism wearing a different key, and the first is the trust anchor itself, which is why this is a document walk and not a check on "path".

func IsPingConversation added in v0.14.0

func IsPingConversation(key string, bucketID int64) bool

IsPingConversation reports whether key is the conversation of the Ping whose bucket is bucketID.

func NextBlockedRetry

func NextBlockedRetry(reason Reason, since, lastAttempt, notBefore time.Time) (time.Time, bool)

NextBlockedRetry returns when a blocked record should next be re-run, and false when it waits for something other than time: the operator serving the project (no_route), or a person's redispatch once the window has passed. notBefore is a throttled record's Verdict.RetryAt; no retry is scheduled before it, and a deadline past the window hands the record to redispatch rather than asking early.

since is when the record entered the reason it is blocked on NOW, which is not when it entered blocked. The reasons carry different windows, so a record that spent a week as the unbounded config_unreadable and then blocks read_failed must get read_failed's twenty-four hours from the moment read_failed began; measured from the older stamp it would get none, which is the retry stranding the work it is there to recover (Copilot on #770). The ledger keeps that stamp per reason (events.retry_since).

The returned time may be in the past — a connector that was not running when a retry came due is late, not excused — so a caller asks whether next is at or before now, never whether it is in the future.

func PingConversationKey added in v0.14.0

func PingConversationKey(pingID int64) string

PingConversationKey is the conversation of a line in a Ping: the Ping itself, whose one Campfire holds all of it. It also marks the record as a Ping's for whoever reads the ledger, since a Ping is answered with no served project; admission writes it only after the Ping read said the bucket is one.

func Run

func Run(ctx context.Context, opts RunOptions) error

Run takes ids until ctx ends, deciding and committing each. It returns nil when ctx is canceled (a shutdown), ctx's error when its deadline passed, and the first other error otherwise: a ledger that cannot load or commit, or a line that cannot be written, is not something to skip past. An event whose decision ctx interrupted is not committed; it stays as it was loaded.

The stdout line is written after the commit, so it is best-effort: a shutdown between the two leaves the verdict written and its line unwritten, and a decided record is not loaded again. The ledger, not the stream, is the record of what was decided.

Types

type Admitter

type Admitter struct {
	// contains filtered or unexported fields
}

Admitter makes verdicts. It is safe for concurrent use: the reads carry their own caches, and nothing else is shared.

func NewAdmitter

func NewAdmitter(policy Policy, reads Reads, opts ...Option) (*Admitter, error)

NewAdmitter validates the policy and the reads it needs.

func (*Admitter) Decide

func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error)

Decide runs the gate and, for an event that passes it, the reads and the trigger rules. The only error it returns is the context's: every other failure is a verdict (blocked or discarded), because a read that failed is a fact about the record, not about the caller.

type AgentRequests added in v0.13.0

type AgentRequests interface {
	CountAgentRequests(ctx context.Context, conversationKey string, agents []int64, window time.Duration) (int, error)
}

AgentRequests is the ledger's count of the allowed agents' mentions it has admitted: those whose requester is one of agents, decided within the last window by the ledger's own clock, in conversationKey when it is not empty and in every conversation when it is. Intake's ledger implements it.

type AskCopy added in v0.14.0

type AskCopy struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

AskCopy is an ask's own words, as one read returned them. Title is empty for a comment or a chat line, whose title is not their own.

type AssignmentReader

type AssignmentReader interface {
	AddedPersonIDs(ctx context.Context, recordingID, eventID int64) (added []int64, found bool, err error)
}

AssignmentReader finds one assignment event among a recording's events and returns the Person ids it added. found is false when the event was not within the read's bound.

type Assignments

type Assignments struct {
	// contains filtered or unexported fields
}

Assignments walks a recording's events, newest first as Basecamp serves them, at most MaxAssignmentPages pages, for one assignment event.

func (*Assignments) AddedPersonIDs

func (a *Assignments) AddedPersonIDs(ctx context.Context, recordingID, eventID int64) ([]int64, bool, error)

AddedPersonIDs implements AssignmentReader.

type BoostReader added in v0.14.0

type BoostReader interface {
	Get(ctx context.Context, boostID int64) (*basecamp.Boost, error)
}

BoostReader reads one boost: who gave it, what it says, and the recording it is on, with that recording's type. A boost.created pointer names the boosted recording but not its type, and the summary read routes by type (basecamp.ErrNoRecordingType), so the boost is read first. *basecamp.BoostsService satisfies it.

type Committer

type Committer struct {
	// contains filtered or unexported fields
}

Committer serializes commits per conversation key, so the verdicts for one conversation reach the ledger one at a time, in the order they finished. Which state is written is the ledger's decision, taken in its own transaction; the committer reports that state rather than guessing it.

func NewCommitter

func NewCommitter(ledger Ledger) *Committer

NewCommitter builds a committer over ledger.

func (*Committer) Commit

func (c *Committer) Commit(ctx context.Context, v Verdict) (Verdict, error)

Commit writes the verdict and returns it with the state the ledger wrote.

type Event

type Event struct {
	// ID is the feed-global event id.
	ID int64
	// EventType is the cataloged type, "comment.created" and so on.
	EventType string
	// BucketID is the project the recording lives in.
	BucketID int64
	// RecordingID is the recording the event references.
	RecordingID int64
	// CreatorID is the person the event is attributed to.
	CreatorID int64
	// PerformedByID is the agent that carried out a delegated action on the
	// creator's behalf; nil for a direct action.
	PerformedByID *int64
	// ActorType is "person" or "agent" on the push lane, empty on the poll lane.
	ActorType string
	// Details is the type-specific detail object, verbatim; nil for the
	// types that publish none. Of the matrix's types only boost.created
	// publishes one: the boost's id, and the event it landed on when it was
	// not the recording itself.
	Details json.RawMessage

	// Revision is the ledger record's revision when it was loaded. It is not
	// part of the feed's pointer: the ledger's Commit compares it, so a
	// decision made on an older load never overwrites a newer one.
	Revision int64
	// SeenAt is when intake wrote the pointer, on this machine's clock — the
	// clock membership listings are stamped with, which is why intake and
	// admission must run on one machine. A backward step of that clock by Δ
	// lets a listing up to Δ older than the event pass as newer. A membership refusal counts as
	// verified only from a project listing asked for after it. The Records
	// adapter must set it; zero falls back to the time admission started
	// deciding, which a busy project can keep from ever verifying a refusal.
	SeenAt time.Time
}

Event is the pointer admission decides on: the fields intake stores for a seen record (internal/connector's Record), and nothing else. It carries no title, no content, no URL and no names; whatever admission needs beyond ids it reads.

func (Event) Performer

func (e Event) Performer() int64

Performer is the effective performer: the delegating agent when the action was delegated, else the creator. It is what the feed's performer filters match and what the trust set is checked against.

type GateResult

type GateResult struct {
	// Rules are the matrix rules still open after the gate, in matrix order.
	// Empty means the event is discarded for Reason.
	Rules []Rule
	// Reason is why the event was discarded; empty when Rules is not.
	Reason Reason
	// ConfirmMembership says the performer is trusted only if a read confirms
	// they are a non-client member of the project (project trust mode, a
	// performer the policy does not name). The gate cannot see membership.
	ConfirmMembership bool
	// Role is the performer's: operator when the policy names them,
	// participant when only membership can trust them.
	Role Role
	// Agent says the performer is an agent the policy allows to mention this
	// one (Trust.AgentIDs). Only the mentioned rule is open to it, its role
	// is participant, and the committer holds it to the agent caps.
	Agent bool
	// InPing says the performer is trusted only as a participant of the Ping
	// the event's bucket is keyed as (Policy.Pings): only once the Ping read
	// confirms the bucket is a Ping, and only by mention.
	InPing bool
}

GateResult is the gate's reading of a pointer.

func Gate

func Gate(ev Event, p Policy, m Matrix) GateResult

Gate decides from the pointer and the policy alone. It makes no read and calls nothing, so it is safe to run on every event the feed serves.

The checks run in a fixed order and the first that ends the event names the reason: the pointer's own ids, the matrix, the agent's own hand, scope, then per rule the trust set and whether the project is served.

func (GateResult) Discarded

func (g GateResult) Discarded() bool

Discarded reports whether the gate ended the event.

type IDSource

type IDSource interface {
	Take(ctx context.Context) (int64, error)
}

IDSource hands over the next event id to admit. Intake's Queue (internal/connector) satisfies it.

type Ledger

type Ledger interface {
	// Commit writes v onto its event's record in one transaction and returns
	// the state it wrote. Within that transaction it must:
	//
	//   - apply only when the record's revision is still v.Revision, the one
	//     the decision was loaded at, and bump it; otherwise return
	//     ErrAlreadyDecided. So an event is admitted at most once however many
	//     fetches, restarts or redispatches decide it, and a decision made on
	//     an older load never overwrites a newer verdict, blocked ones
	//     included;
	//   - for an admitted verdict, read the conversation in the same
	//     transaction and write queued when it is live — a task running, or
	//     an admitted record not yet dispatched, since that record becomes the
	//     task — so an event queued before the task closes joins it and one
	//     after starts a new task;
	//   - write content only for an admitted verdict, which it may write as
	//     queued;
	//   - keep a throttled verdict's RetryAt, which NextBlockedRetry needs.
	Commit(ctx context.Context, v Verdict) (State, error)
}

Ledger is the durable half of a verdict. Intake's SQLite ledger implements it (internal/connector, Ledger.Admission).

type Line

type Line struct {
	Type         string  `json:"type"`
	EventID      int64   `json:"event_id"`
	EventType    string  `json:"event_type"`
	Trigger      Trigger `json:"trigger,omitempty"`
	Class        string  `json:"class,omitempty"`
	BucketID     int64   `json:"bucket_id"`
	RecordingID  int64   `json:"recording_id"`
	RecordingURL string  `json:"recording_url,omitempty"`
	RequesterID  int64   `json:"requester_id"`
	State        State   `json:"state"`
	Reason       Reason  `json:"reason,omitempty"`
}

Line is the one line admission writes to stdout per verdict. It names the event, the decision and where it points; no field carries content.

func LineFor

func LineFor(v Verdict) Line

LineFor builds the stdout line for a verdict.

type Matrix

type Matrix map[string][]Rule

Matrix maps an event type to the rules it can admit under, in the order they are tried. It is data: a later step adds rows without touching the gate.

func V1Matrix

func V1Matrix() Matrix

V1Matrix returns the trigger matrix: version 1, plus boosted. Every other cataloged type is discarded as not_in_matrix. A fresh copy each call, so no caller can widen another's.

type MemberReader

type MemberReader interface {
	NonClientMember(ctx context.Context, bucketID, personID int64, asOf time.Time) (bool, error)
}

MemberReader answers whether a person is a non-client member of a project. Only project trust mode consults it.

A "no" discards the event, so it must come from a listing read at or after asOf — the moment the event was seen. When the reader holds only an older listing and may not read a fresh one yet, it returns ErrMembershipUnverified and the record is held rather than refused.

type Members

type Members struct {
	// contains filtered or unexported fields
}

Members reads a project's people and answers membership from that listing.

func NewMembers

func NewMembers(client *basecamp.AccountClient, now func() time.Time) *Members

NewMembers builds the cached membership read.

func (*Members) NonClientMember

func (m *Members) NonClientMember(ctx context.Context, bucketID, personID int64, asOf time.Time) (bool, error)

NonClientMember implements MemberReader. A member for trust is a person the project's listing names who is neither a client nor an Agent principal (the listing serves agents, with client false).

"Yes" is served from a cached listing for its TTL. "No" discards the event, so it is served only from a listing read at or after asOf; everyone the listing did not yet know about, or knew as a client since promoted, was added after the event and cannot have been trusted at it. With only an older listing, the listing is read again — at most once per MembershipRefreshFloor per project, so a burst of events costs one listing, which then answers every event seen before it. Inside the floor the answer is ErrMembershipUnverified: held, never refused.

type Option

type Option func(*Admitter)

Option adjusts an Admitter.

func WithClock

func WithClock(now func() time.Time) Option

WithClock replaces the admitter's clock. Tests use it.

func WithMatrix

func WithMatrix(m Matrix) Option

WithMatrix replaces the trigger matrix.

func WithReadRetry

func WithReadRetry(attempts int, backoff time.Duration) Option

WithReadRetry sets how many times a read is attempted and the first backoff, which doubles up to thirty seconds.

func WithServed

func WithServed(served func() (map[int64]Project, error)) Option

WithServed makes the served projects live: admission reads them at each decision instead of from the policy it was built with.

The dispatcher already rereads connect.json, so without this, serving a project while the connector runs changed only half the answer. Unserving one left its events admitted and then never started — no work and no holding reply, so the person who mentioned the agent got nothing. Serving one left them blocked no_route until a restart, and the holding reply's own remedy could not work: a redispatch re-runs admission, against the same stale policy (Copilot on #765).

Only the projects are live. Trust is not: who may drive the agent is a different kind of decision, and changing it under a running connector is not something this option quietly does.

func WithSleep

func WithSleep(sleep func(context.Context, time.Duration) error) Option

WithSleep replaces the wait between read attempts. Tests use it.

type PingReader added in v0.14.0

type PingReader interface {
	Ping(ctx context.Context, bucketID int64) (*basecamp.Ping, error)
}

PingReader reads the Ping a bucket is, as the agent sees it: People are the others in it. It returns nil, and no error, when the bucket is not a Ping the agent is in, which is the answer for every project.

type PingTrust added in v0.14.0

type PingTrust struct {
	Participants []int64 `json:"participants,omitempty"`
}

PingTrust is one Ping's entry in connect.json: the people who reach the agent in that Ping and nowhere else (--allow-in-ping). Each is a participant there, by mention only, in every trust mode; anywhere else they are whoever the rest of the policy makes them.

type Pings added in v0.14.0

type Pings struct {
	// contains filtered or unexported fields
}

Pings reads a bucket as a Ping, remembering for NotAPingTTL each bucket that is not one.

func NewPings added in v0.14.0

func NewPings(client *basecamp.AccountClient, now func() time.Time) *Pings

NewPings builds the Ping read.

func (*Pings) Ping added in v0.14.0

func (p *Pings) Ping(ctx context.Context, bucketID int64) (*basecamp.Ping, error)

Ping implements PingReader. Basecamp answers 404 for a bucket that is not a Ping the caller is in, and 403 to a caller whose company has Pings turned off, so both mean no Ping. Every other failure is returned as itself.

type Policy

type Policy struct {
	// AgentID is the agent's own Person id. Required.
	AgentID int64 `json:"-"`
	// Buckets is the --project scope. Empty means every project the agent can
	// see.
	Buckets []int64 `json:"-"`

	Trust Trust `json:"trust"`
	// Projects maps a bucket id to the entry for the project it serves.
	Projects map[int64]Project `json:"projects,omitempty"`
	// Pings maps a Ping's bucket id to who may reach the agent in that Ping
	// alone. Trust, like Trust: read once, never live.
	Pings map[int64]PingTrust `json:"pings,omitempty"`
	// ProjectsUnknown says the served projects could not be read at all —
	// connect.json is unreadable, or now names another agent. It is not the
	// same as serving none, and the difference is what a person is told: a
	// record whose answer turns on the list is held as a configuration
	// error, rather than answered with a claim about the project that is
	// false while the file is broken.
	ProjectsUnknown bool `json:"-"`
}

Policy is what admission reads from connect.json, plus the two facts that never come from that file: the agent's own Person id (from the profile's verified identity) and the --project scope.

connect.json as a whole is written by `basecamp connect setup` (plan step 16), which also carries the driver, concurrency and deadline; this is only the part admission decides from.

func ParsePolicy

func ParsePolicy(data []byte) (Policy, error)

ParsePolicy decodes the admission part of connect.json. Unknown keys are ignored: the file carries settings that belong to other steps, and this reader has no business refusing a file over the driver's half of it. The caller sets AgentID and Buckets, then calls Validate.

Noncanonical and repeated keys are refused, which is not the same laxness. An unknown key cannot reach anything this decides from; a key spelled "Trust" or "PATH" reaches exactly that, because encoding/json matches it. Ignoring one and refusing the other is the difference between tolerating a setting that is not ours and accepting an authorization we cannot see. See CheckCanonicalKeys.

func (Policy) Validate

func (p Policy) Validate() error

Validate refuses a policy under which trust could be misread. Every check here fails closed: a policy that does not validate admits nothing.

type Project

type Project struct {
	// Class is the project's classification, carried on the record.
	Class string `json:"class,omitempty"`
	// WatchCompletions makes the agent a driver of the project: every trusted
	// completion in it is admitted as trigger completed, without the agent
	// being assigned or subscribed.
	WatchCompletions bool `json:"watch_completions,omitempty"`

	// LegacyPath is the directory a connector that routed projects to
	// directories ran this project's work in. Nothing reads it: a task runs
	// where the connector was started. It is still a field because
	// setup.Parse refuses an unknown key, and every connect.json written
	// before the paths went has "path" on every project — deleting the field
	// outright would stop every connector already set up. setup.Parse zeroes
	// it, and omitempty keeps it out of everything written from here, so the
	// next `connect setup` writes the key away for good.
	LegacyPath string `json:"path,omitempty"`
}

Project is one served project's entry in connect.json: a project the agent may be driven from, and how admission treats it. The entries are local: nothing read from Basecamp can add or change one, which is what makes the list an answer to "which projects may drive this agent" that only the operator writes. It matters most under TrustProject, where any non-client member of a served project can drive the agent.

No directory is associated with a project. The connector runs where it was started; a task that needs a clone or a directory of its own is the agent's business to make.

func (*Project) UnmarshalJSON

func (p *Project) UnmarshalJSON(data []byte) error

UnmarshalJSON refuses a null entry, and decodes an object strictly.

Both matter, and the first is the one that bites. A JSON null decodes into a struct as the zero value without an error, so `"projects":{"123":null}` would read as project 123 served with no settings. Nothing noticed that before this file stopped carrying a path, because the path was required and a null has none — the check was doing two jobs and looked like it was doing one. connect.json is the trust anchor: a half-edited file, a bad merge or a truncated write has to withhold authorization, not grant it (Copilot on #765).

The strictness is the second job the outer decoder used to do here. setup.Parse refuses an unknown key so that a misspelled watch_completion is a refusal rather than a project the operator believes is driven and is not — and a type's own UnmarshalJSON does not inherit that setting, so it is applied again here. It binds admission's reader too, which is the narrower of the two behaviors and the right one: the keys a project entry may carry are all known here, unlike the file's own, which carry other steps' settings.

type Reads

type Reads struct {
	Summaries     SummaryReader
	Subscriptions SubscriptionReader
	Assignments   AssignmentReader
	Boosts        BoostReader
	Pings         PingReader
	Members       MemberReader
}

Reads bundles the reads admission may make. Members may be nil unless the policy's trust mode is project.

func NewSDKReads

func NewSDKReads(cfg *basecamp.Config, tokens basecamp.TokenProvider, accountID string, opts ...basecamp.ClientOption) Reads

NewSDKReads builds the reads over a client acting as the agent, in the account. The client is built here, with the SDK's own retries off: the admitter owns the retry budget (DefaultReadAttempts per read), and a second layer underneath would multiply it.

type Reason

type Reason string

Reason explains a blocked or discarded verdict.

const (
	// ReasonInvalidPointer: an id the pointer must carry is missing.
	ReasonInvalidPointer Reason = "invalid_pointer"
	// ReasonNotInMatrix: the event type admits under no trigger.
	ReasonNotInMatrix Reason = "not_in_matrix"
	// ReasonAgentAuthored: the agent created or performed the event, or an
	// agent actor did.
	ReasonAgentAuthored Reason = "agent_authored"
	// ReasonDelegated: an agent performed the event on someone's behalf.
	ReasonDelegated Reason = "delegated"
	// ReasonOutOfScope: the bucket is outside the --project scope.
	ReasonOutOfScope Reason = "out_of_scope"
	// ReasonUntrustedPerformer: the performer is not in the trust set.
	ReasonUntrustedPerformer Reason = "untrusted_performer"
	// ReasonAssignmentNotOperator: an assignment performed by anyone other
	// than the operator or a person the allowlist names whom
	// allow_assignments opts in.
	ReasonAssignmentNotOperator Reason = "assignment_not_operator"
	// ReasonUntrustedAuthor: the recording carrying the instruction was
	// written by someone outside the trust set.
	ReasonUntrustedAuthor Reason = "untrusted_author"
	// ReasonStale: the recording is still drafted or has been trashed.
	ReasonStale Reason = "stale"
	// ReasonNotAddressed: nothing about the recording targets the agent.
	ReasonNotAddressed Reason = "not_addressed"
	// ReasonAgentNotAllowed: another agent's event, when the policy allows
	// agents to mention this one and does not name that agent.
	ReasonAgentNotAllowed Reason = "agent_not_allowed"
	// ReasonAgentThreadCap: an allowed agent's mention past the per-thread
	// cap (Trust.AgentThreadCap within AgentCapWindow).
	ReasonAgentThreadCap Reason = "agent_thread_cap"
	// ReasonAgentDailyCap: an allowed agent's mention past that agent's cap
	// across every thread (Trust.AgentDailyCap within AgentCapWindow).
	ReasonAgentDailyCap Reason = "agent_daily_cap"
)

Discard reasons. Each is a verified verdict — the gate's reading of the pointer and policy, or a read that answered with what the verdict needs — never a failed or incomplete read.

const (
	// ReasonReadFailed: a read failed after its retries.
	ReasonReadFailed Reason = "read_failed"
	// ReasonReadUnresolved: a chat line found under no visible Campfire.
	ReasonReadUnresolved Reason = "read_unresolved"
	// ReasonThrottled: the server throttled a read for longer than a decision
	// waits in place. Retried at Verdict.RetryAt, never before.
	ReasonThrottled Reason = "throttled"
	// ReasonTrustUnverified: in project trust mode, the only project listing
	// at hand predates the event and a fresh one was read too recently to read
	// again; the person may have been added since.
	ReasonTrustUnverified Reason = "trust_unverified"
	// ReasonDeltaUnverified: the assignment event was not found within the
	// events read's bound, or was found without its details, so who was added
	// is unknown.
	ReasonDeltaUnverified Reason = "delta_unverified"
	// ReasonBucketMismatch: the recording is not in the bucket the pointer
	// named — moved since the event, most likely.
	ReasonBucketMismatch Reason = "bucket_mismatch"
	// ReasonUnroutable: the SDK has no typed read for the pointer's type. Not
	// retried on a timer; only a redispatch re-runs it.
	ReasonUnroutable Reason = "unroutable"
	// ReasonNoRoute: shared by both states — a mentioned or assigned record in
	// a project connect.json does not serve is blocked (and answered with a
	// holding reply); any other trigger there is discarded.
	//
	// The stored value stays "no_route", the word a served project was called
	// by when it also named a directory. It is not renamed with the concept:
	// it is written onto the record, and both the holding reply and the
	// retraction that answers it read the record's reason back (askStillOpen,
	// holdingReplyReason). A ledger in use carries rows and pending outbox
	// intents written with this value, and a row is read as it was written.
	ReasonNoRoute Reason = "no_route"
	// ReasonConfigUnreadable: the record's answer turns on which projects
	// connect.json serves, and connect.json could not be read. Not
	// no_route: that says the operator has not served this project, which
	// would be a false thing to say — and to post a holding reply about —
	// when the truth is that nothing could read the file.
	//
	// It is the one blocked reason with no window: the intake sweep re-offers
	// it every BlockedRetryInterval for as long as it stands (NextBlockedRetry,
	// internal/connector Intake.sweepBlockedRetries). A day is the right bound
	// for a failing server, which is not coming back on its own after one; a
	// broken connect.json is a local file, and an operator away for a week is
	// ordinary. `basecamp connect redispatch <id>` still runs one at once.
	ReasonConfigUnreadable Reason = "config_unreadable"
)

Blocked reasons. A blocked record is retained and recovered; it is never a tombstone.

type Records

type Records interface {
	LoadUndecided(ctx context.Context, id int64) (ev Event, ok bool, err error)
}

Records loads a record for admission. It returns a record still being decided — seen, or blocked (recovery and redispatch decide a blocked record again) — with Event.Revision set to the record's revision as loaded. ok is false when the id is unknown or the record is past deciding, so it is skipped rather than decided twice.

This is the seam where intake hands admission an event; intake's ledger implements it (internal/connector, Ledger.Admission).

type ReplyDestination

type ReplyDestination struct {
	Kind        ReplyKind `json:"kind"`
	RecordingID int64     `json:"recording_id"`
}

ReplyDestination is where the worker's acknowledgement and reply, and the connector's lifecycle messages, are posted.

type ReplyKind

type ReplyKind string

ReplyKind names where a reply to the record goes.

const (
	// ReplyComment is a comment on ReplyDestination.RecordingID.
	ReplyComment ReplyKind = "comment"
	// ReplyChatLine is a line in the Campfire ReplyDestination.RecordingID.
	ReplyChatLine ReplyKind = "chat_line"
)

type Role added in v0.13.0

type Role string

Role is whose request an admitted event is. Operators are the people whose word authorizes the agent: the operator, and anyone named in the allowlist. Participants are the wider set project trust admits — any non-client member of the project — whose requests reach the agent but carry no authority of their own. Admission says which; what each may get the agent to do is the policy of whoever reads the request.

const (
	RoleOperator    Role = "operator"
	RoleParticipant Role = "participant"
)

type Rule

type Rule struct {
	Trigger Trigger
	// OperatorOnly restricts the rule to events the operator performed,
	// whatever the trust mode, and to the people the allowlist names when
	// Trust.AllowAssignments opts them in. Assignments run the agent against
	// a recording on the assigner's say-so, so no broadened mode extends to
	// them by itself.
	OperatorOnly bool
	// RequiresServed discards the rule at the gate when connect.json does not
	// serve the project. Only mentioned and assigned are answered in an
	// unserved project (blocked(no_route) and a holding reply); every other
	// trigger is discarded there, so it is cheaper to discard it before any
	// read.
	RequiresServed bool
	// Acknowledge says a person asked for something, so the worker's
	// acknowledgement and the thirty-second guard apply. A completion, a
	// subscription comment or a boost is not a request.
	Acknowledge bool
}

Rule is one row of the trigger matrix: a trigger an event type can admit under, and the metadata the gate needs to judge it without a read.

type RunOptions

type RunOptions struct {
	Source    IDSource
	Records   Records
	Admitter  *Admitter
	Committer *Committer
	// Workers is the fetcher pool size; DefaultWorkers when zero.
	Workers int
	// Lines receives one NDJSON line per committed verdict.
	Lines io.Writer
	// LineWriter, when set, is the writer the lines go through, and Lines is
	// ignored: one sink has one writer and one lock, and a caller wiring
	// admission beside intake passes the same writer to both.
	LineWriter *ndjson.Writer
	Logger     *slog.Logger
}

RunOptions configures the admission loop.

type Snapshot

type Snapshot struct {
	Type      string    `json:"type"`
	Title     string    `json:"title"`
	AppURL    string    `json:"app_url"`
	Content   string    `json:"content"`
	UpdatedAt time.Time `json:"updated_at"`
	// ProjectName and RequesterName are what the session reading the
	// handoff calls them, so it need not ask Basecamp as the agent.
	// RequesterName is known only when the requester wrote the recording.
	ProjectName   string `json:"project_name,omitempty"`
	RequesterName string `json:"requester_name,omitempty"`
	// Role is whose request this is: an operator's or a participant's.
	Role Role `json:"role,omitempty"`
	// Owner says the operator connect.json names asked, in words they wrote:
	// they mentioned the agent, wrote to it in their direct Ping, or assigned
	// it a recording they wrote. A
	// comment on a followed thread, a completion and a boost are never the
	// owner's word; an operator's go on a declared ask is a separate
	// approval (AskRecordingID) and does not make one. Someone named with --allow is an operator but never the
	// owner, and neither is the owner bringing in someone else's words: an
	// allowlisted person's to-do the owner moves in is that person's.
	Owner bool `json:"owner,omitempty"`
	// Ask is the boosted post as this decision read it, on an operator's
	// boost that may be a declared ask's go: the exact words the fingerprint
	// was taken over. A go approves these words, not the post as it is
	// later, so the handoff gives them to the worker to act on.
	Ask *AskCopy `json:"ask,omitempty"`
}

Snapshot is the recording as admission read it: the instruction the worker is handed, versioned by UpdatedAt. The worker re-reads the live recording for anything newer.

type State

type State string

State is a verdict's ledger state. The names match intake's RecordState.

const (
	StateAdmitted  State = "admitted"
	StateQueued    State = "queued"
	StateBlocked   State = "blocked"
	StateDiscarded State = "discarded"
	// StateHeld is never a verdict. It is what the ledger writes instead of
	// admitted or queued for a record a hold tagged for review, which waits
	// for a person.
	StateHeld State = "held"
)

type SubscriptionReader

type SubscriptionReader interface {
	Subscribed(ctx context.Context, recordingID int64) (bool, error)
}

SubscriptionReader answers whether the agent — the identity the reads run as — is subscribed to a recording.

type Subscriptions

type Subscriptions struct {
	// contains filtered or unexported fields
}

Subscriptions reads the agent's own subscription to a recording, cached for SubscriptionTTL. Only answers are cached; a failed read is retried fresh.

func NewSubscriptions

func NewSubscriptions(client *basecamp.AccountClient, now func() time.Time) *Subscriptions

NewSubscriptions builds the cached subscription read.

func (*Subscriptions) Subscribed

func (s *Subscriptions) Subscribed(ctx context.Context, recordingID int64) (bool, error)

Subscribed implements SubscriptionReader. Only "subscribed" is served from the cache. "Not subscribed" is always read fresh, because it discards the event: a subscription made a minute ago must not be answered by a snapshot from before it.

type SummaryReader

type SummaryReader interface {
	Summarize(ctx context.Context, ref basecamp.RecordingRef) (*basecamp.RecordingSummary, error)
}

SummaryReader resolves a recording pointer into the SDK's compact projection (plan step 10). *basecamp.RecordingsService satisfies it.

type ThrottledError

type ThrottledError struct {
	RetryAt time.Time
	Err     error
}

ThrottledError wraps a read the server throttled for longer than one decision may wait. The record is held until RetryAt, never asked sooner.

func (*ThrottledError) Error

func (e *ThrottledError) Error() string

func (*ThrottledError) Unwrap

func (e *ThrottledError) Unwrap() error

type Trigger

type Trigger string

Trigger names why an admitted event is the agent's business.

const (
	TriggerMentioned  Trigger = "mentioned"
	TriggerSubscribed Trigger = "subscribed"
	TriggerAssigned   Trigger = "assigned"
	TriggerCompleted  Trigger = "completed"
	// TriggerBoosted: someone boosted a recording the agent wrote. The boost
	// is the content; the boosted recording is what it answers.
	TriggerBoosted Trigger = "boosted"
	// TriggerPinged: an operator's line in a direct Ping with the agent, the
	// agent and one other person. Every line there is said to the agent, so
	// none needs a mention; in a group Ping a line is the agent's business
	// only as a mention.
	TriggerPinged Trigger = "pinged"
)

The triggers. queued is step 25, still to come; edited is step 28.

type Trust

type Trust struct {
	Mode TrustMode `json:"mode"`
	// OperatorID is the Person id of the person who approved the connection.
	OperatorID int64 `json:"operator_id"`
	// AllowlistIDs are the Person ids trusted as operators besides the
	// operator, in allowlist or project mode.
	AllowlistIDs []int64 `json:"allowlist_ids,omitempty"`
	// AllowAssignments lets the people AllowlistIDs names assign the agent
	// work, as the operator does. Off, assignments are the operator's alone.
	AllowAssignments bool `json:"allow_assignments,omitempty"`

	// AgentIDs are other agents (Agent persons) allowed to wake this one by
	// @mentioning it. Empty, the default, admits no agent at all. An allowed
	// agent reaches the agent by mention only, never by assignment, by a
	// comment on a thread it follows or by a completion, and its request is a
	// participant's, never an operator's. Any trust mode may name them.
	AgentIDs []int64 `json:"agent_ids,omitempty"`
	// AgentThreadCap is how many allowed agents' mentions one conversation
	// (a thread, or a Campfire) admits within AgentCapWindow, across every
	// allowed agent. Zero means DefaultAgentThreadCap.
	AgentThreadCap int `json:"agent_thread_cap,omitempty"`
	// AgentDailyCap is how many of one allowed agent's mentions are admitted
	// within AgentCapWindow across every conversation, so an agent opening a
	// new thread for each reply is bounded too. Zero means
	// DefaultAgentDailyCap.
	AgentDailyCap int `json:"agent_daily_cap,omitempty"`
}

Trust is the trust section of connect.json.

func (Trust) DailyCap added in v0.13.0

func (t Trust) DailyCap() int

DailyCap is the per-agent cap in force: AgentDailyCap, or the default when unset.

func (Trust) ThreadCap added in v0.13.0

func (t Trust) ThreadCap() int

ThreadCap is the per-conversation cap in force: AgentThreadCap, or the default when unset.

type TrustMode

type TrustMode string

TrustMode names who, besides the operator, may drive the agent.

const (
	// TrustOperator trusts the operator alone. The default.
	TrustOperator TrustMode = "operator"
	// TrustAllowlist trusts the operator and a list of Person ids.
	TrustAllowlist TrustMode = "allowlist"
	// TrustProject trusts the operator, anyone in the allowlist, and any
	// non-client member of the event's project — the members as
	// participants.
	TrustProject TrustMode = "project"
)

type Verdict

type Verdict struct {
	EventID     int64
	EventType   string
	BucketID    int64
	RecordingID int64
	// Revision is the record revision the decision started from.
	Revision int64
	// RetryAt is set on a blocked(throttled) verdict: the server's deadline,
	// before which the record is not decided again.
	RetryAt time.Time
	// RequesterID is who asked: the performer, or, when a participant wrote
	// the words the performer brought in, that participant, the person the
	// role was settled on.
	RequesterID int64
	// Role is whose request an admitted event is, settled on everyone whose
	// trust admitted it: the performer and, for the triggers whose
	// instruction is the recording's content, its author.
	Role Role

	State  State
	Reason Reason

	// The rest is set once the recording was read; a gate discard carries
	// none of it.
	Trigger Trigger
	// Acknowledge says the worker acknowledges and the guard arms. False for
	// completed, subscribed and boosted.
	Acknowledge bool
	// ConversationKey groups events into one task: the commented recording
	// for a comment, the Campfire for a chat line, the recording otherwise.
	ConversationKey string
	Reply           *ReplyDestination
	// Served is whether connect.json serves the record's project, and Class
	// is that entry's classification. No directory comes from connect.json:
	// the connector runs where it was started.
	Served bool
	Class  string
	// RecordingURL is the recording's app URL, once read. A URL, not content.
	RecordingURL string
	// Snapshot is set on an admitted verdict only.
	Snapshot *Snapshot

	// AskRecordingID is set on an admitted boost the operator gave, on the
	// agent's own recording: the ask that boost would be the go for, if the
	// agent declared that recording an ask. Admission cannot see asks; the
	// ledger decides in the verdict's own transaction, so one ask gets one go.
	// Only the operator's boost carries it, in every trust mode: as with an
	// assignment, an allowlist or a broader trust mode never extends it.
	AskRecordingID int64
	// AskContentHash is AskFingerprint of the boosted recording's title and
	// content as this decision read it. The ledger gives the go only when it equals the
	// hash the ask was declared with: an ask edited since it was declared is
	// void, and its boost is an ordinary reply.
	AskContentHash string
	// AskBoostID is the boost's own id. Basecamp's boost ids only grow, so the
	// ledger orders it against the boosts already on the ask when it was
	// declared, on Basecamp's clock and no other.
	AskBoostID int64
	// contains filtered or unexported fields
}

Verdict is admission's decision about one event.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL