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 ¶
- 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.
- In project trust mode a performer other than the operator 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.
- The RecordingSummary read (the SDK's step-10 helper) resolves the recording through the typed read its event type names. A recording still drafted or since trashed is stale.
- 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) or completed (trusted completer and a stake: the served project's watch_completions flag, an assignment, or a subscription).
- 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. Assignments are operator-only in every trust mode. 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.
Invariants ¶
Every rule below is held by a test that fails without it.
- 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; the feed's actor_types=person filter excludes agents at source.
- An assignment is admitted only when the operator performed it, 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.
- 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.
- 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.
- 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 boosted and queued triggers are step 25 and edited is step 28: each is a row added to the Matrix plus its rule, and the gate does not change.
Index ¶
- Constants
- Variables
- func CheckCanonicalKeys(data []byte) error
- func NextBlockedRetry(reason Reason, since, lastAttempt, notBefore time.Time) (time.Time, bool)
- func Run(ctx context.Context, opts RunOptions) error
- type Admitter
- type AssignmentReader
- type Assignments
- type Committer
- type Event
- type GateResult
- type IDSource
- type Ledger
- type Line
- type Matrix
- type MemberReader
- type Members
- type Option
- type Policy
- type Project
- type Reads
- type Reason
- type Records
- type ReplyDestination
- type ReplyKind
- type Rule
- type RunOptions
- type Snapshot
- type State
- type SubscriptionReader
- type Subscriptions
- type SummaryReader
- type ThrottledError
- type Trigger
- type Trust
- type TrustMode
- type Verdict
Constants ¶
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.
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 // MaxAssignmentPages bounds the events read for an assignment delta. MaxAssignmentPages = 5 )
Read bounds.
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.
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.
const DefaultWorkers = 4
DefaultWorkers is the admission fetcher pool size.
const MembershipRefreshFloor = 30 * time.Second
MembershipRefreshFloor bounds how often a project's listing is read.
Variables ¶
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.
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 CheckCanonicalKeys ¶
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 NextBlockedRetry ¶
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 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 ¶
NewAdmitter validates the policy and the reads it needs.
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 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 ¶
NewCommitter builds a committer over ledger.
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 v1
// trigger types, which publish none.
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.
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 other than the operator). The gate cannot see membership.
ConfirmMembership 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 ¶
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.
type Matrix ¶
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.
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 WithReadRetry ¶
WithReadRetry sets how many times a read is attempted and the first backoff, which doubles up to thirty seconds.
func WithServed ¶
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.
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"`
// 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 ¶
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.
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 ¶
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
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 but the // operator. 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" )
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 Rule ¶
type Rule struct {
Trigger Trigger
// OperatorOnly restricts the rule to events the operator performed,
// whatever the trust mode. Assignments run the agent against a recording
// on the assigner's say-so, so no broadened mode extends to them.
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 or a
// subscription comment 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"`
}
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 ¶
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 ¶
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 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 in allowlist mode.
AllowlistIDs []int64 `json:"allowlist_ids,omitempty"`
}
Trust is the trust section of connect.json.
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 and any non-client member of the // event's project. 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 the performer whose trust admitted the event.
RequesterID int64
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 and subscribed.
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
}
Verdict is admission's decision about one event.