// Package bot — typed sentinel errors used by the chatbot service layer. // // Handlers map these onto HTTP status codes via MapError. The service // layer never returns raw pgx/sqlc errors to handlers; instead it wraps // them in one of the sentinels below so the handler->HTTP mapping stays // in one place. package bot import "errors" // ErrSessionNotFound is returned when an owner-scoped or super-admin // read cannot find a session matching the (id, client_id, [created_by], // is_deleted=false) tuple. Maps to 404. var ErrSessionNotFound = errors.New("bot: session not found") // ErrAddRemoveConflict is returned by PatchScope when the same id // appears in both add and remove sets for documents or folders. Maps to // 400 with reason add_remove_conflict. var ErrAddRemoveConflict = errors.New("bot: add_remove_conflict") // ErrDocumentCrossClient is returned by PatchScope when one of the // candidate document ids belongs to a different client than the // session's client. Maps to 400 with reason document_cross_client. var ErrDocumentCrossClient = errors.New("bot: document_cross_client") // ErrFolderCrossClient is returned by PatchScope when one of the // candidate folder ids belongs to a different client than the session's // client. Maps to 400 with reason folder_cross_client. var ErrFolderCrossClient = errors.New("bot: folder_cross_client") // ErrInvalidLimit is returned when request.limit is outside [1, 200]. // Maps to 400. var ErrInvalidLimit = errors.New("bot: invalid limit; must be in [1, 200]") // ErrInvalidOffset is returned when request.offset is below 0. Maps to // 400. var ErrInvalidOffset = errors.New("bot: invalid offset; must be >= 0") // ErrMissingClientID is returned when a super-admin route is called // without the required clientId query parameter. Maps to 400. var ErrMissingClientID = errors.New("bot: clientId is required") // ---------- M3: FastAPI client sentinels ---------- // // Each sentinel below is wrapped by an attemptError so the service // layer can errors.Is(err, sentinel) AND read AttemptCount via the // AttemptCounter interface in one error. // ErrAgentApplicationError is returned when FastAPI replies with HTTP // 200 and status:"error". Plan §7: do not retry; persist the upstream // error JSON in bot_turns.error. var ErrAgentApplicationError = errors.New("bot: agent_application_error") // ErrAgentMissingAnswer is returned when FastAPI replies with HTTP 200 // and status:"success" but answer.text is empty, whitespace-only, or // the answer field is null/missing. Plan §7: do not retry. var ErrAgentMissingAnswer = errors.New("bot: agent_missing_answer") // ErrAgentInvalidResponse is returned when FastAPI replies with HTTP // 200 but the body is not parseable JSON or fails structural validation. // Plan §7: do not retry. var ErrAgentInvalidResponse = errors.New("bot: agent_invalid_response") // ErrAgentTransportError is returned when the FastAPI client exhausts // its retry budget on transport-level failures (timeouts, retryable // HTTP statuses 429/5xx). The handler maps this to HTTP 502. Plan §7. var ErrAgentTransportError = errors.New("bot: agent_transport_error") // ErrHealthStatusNotOK is returned by FastAPIClient.Health when the // /health response status field is not "ok". Plan §7. var ErrHealthStatusNotOK = errors.New("bot: health status not ok") // ErrHealthMissingService is returned by FastAPIClient.Health when the // service or version fields are empty. Plan §7. var ErrHealthMissingService = errors.New("bot: health service or version empty") // ---------- M4: AddTurn sentinels ---------- // // Each maps to a specific HTTP status via mapBotError: // 400: prompt_empty, prompt_too_long // 409: turn_in_flight, prior_turn_in_flight, already_complete, // turn_session_deleted, ordinal_out_of_range, prompt_mismatch, // turn_superseded // 410: session_deleted_during_call // ErrTurnInFlight: existing row at the requested ordinal has status // in_flight. The same caller cannot resubmit while their turn is still // running. Plan §6. var ErrTurnInFlight = errors.New("bot: turn_in_flight") // ErrPriorTurnInFlight: a different turn at a lower ordinal is still // in_flight, so the partial unique index `bot_turns_one_inflight_per_session` // rejects a new in_flight insert. Plan §6. var ErrPriorTurnInFlight = errors.New("bot: prior_turn_in_flight") // ErrAlreadyComplete: existing row at the requested ordinal has status // completed. Retries of completed turns are rejected. Plan §6. // // The sentinel intentionally chains to ErrOrdinalOutOfRange via the Is // method below: a concurrent submission whose ordinal lands on a row // the winner just completed is semantically "out of range" (the next // natural ordinal moved past the completed row). Plan §11's // concurrency invariant lists ErrOrdinalOutOfRange as one of the // acceptable 409 outcomes; chaining lets ErrAlreadyComplete satisfy // `errors.Is(err, ErrOrdinalOutOfRange)` without losing its own // identity for the HTTP retry-of-completed scenario. var ErrAlreadyComplete = alreadyCompleteError{} // alreadyCompleteError is the concrete type backing ErrAlreadyComplete. // The Error string matches the sentinel-style "bot: already_complete" // so errors.Is(err, ErrAlreadyComplete) and substring assertions on // the HTTP error message both work. type alreadyCompleteError struct{} func (alreadyCompleteError) Error() string { return "bot: already_complete" } // Is satisfies errors.Is so ErrAlreadyComplete chains to // ErrOrdinalOutOfRange. Without this, the M4 concurrency test's // loser-B case (caller's ordinal lands on the row the winner just // completed) would surface as a non-409 default branch. Plan §11's // loss set explicitly enumerates ErrOrdinalOutOfRange. func (alreadyCompleteError) Is(target error) bool { if target == ErrOrdinalOutOfRange { return true } _, ok := target.(alreadyCompleteError) return ok } // ErrTurnSessionDeleted: existing row at the requested ordinal has // status session_deleted. Distinct from ErrAlreadyComplete. Plan §6. var ErrTurnSessionDeleted = errors.New("bot: turn_session_deleted") // ErrOrdinalOutOfRange: the requested ordinal does not match the // algorithm's expected ordinal (last_terminal+1 for new, last_seen+1 // when in_flight, last_terminal_ordinal for retry of errored/abandoned). // Plan §6. var ErrOrdinalOutOfRange = errors.New("bot: ordinal_out_of_range") // ErrPromptMismatch: caller is retrying an errored/abandoned turn but // the supplied prompt does not match the persisted prompt. Plan §6: // retries must reuse the same prompt verbatim. var ErrPromptMismatch = errors.New("bot: prompt_mismatch") // ErrTurnSuperseded: the tx2 compare-and-set affected zero rows because // either attempt_id no longer matches or status is no longer in_flight. // Indicates the row was abandoned/reset by another caller while the // FastAPI call was in flight. Plan §6 + plan §11. var ErrTurnSuperseded = errors.New("bot: turn_superseded") // ErrSessionDeletedDuringCall: the tx2 LockBotSessionForOwner found the // session is_deleted=true after a successful tx1 + FastAPI call. The // service marks the in_flight row as session_deleted before returning // this sentinel. HTTP 410. Plan §6. var ErrSessionDeletedDuringCall = errors.New("bot: session_deleted_during_call") // ErrPromptEmpty: the supplied prompt is empty or whitespace-only. // Validation runs before any tx so no row is inserted. HTTP 400. var ErrPromptEmpty = errors.New("bot: prompt_empty") // ErrPromptTooLong: the supplied prompt exceeds cfg.MaxTurnChars in // rune count. HTTP 400. var ErrPromptTooLong = errors.New("bot: prompt_too_long")