owpengram-server/internal/store/postgres/verification.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

1342 lines
51 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// VerificationStore is the PostgreSQL implementation of the official platform
// verification store: applications filed through @verifybot, the immutable
// per-application history and the applicant-notification outbox.
//
// Three properties are load bearing and every method below exists to keep them:
//
// - "approved implies target verified". The status change, the history row, the
// outbox row and the peer flag are written in one transaction. The flag is set
// by the caller-supplied applyVerified callback, which runs inside that
// transaction (see VerificationTxFromContext), so a callback failure rolls the
// decision back and a crash can never leave an approved application whose
// target is not verified.
// - Exactly one decision per application. Every mutation is guarded by
// WHERE id = $1 AND version = $2 on top of a SELECT ... FOR UPDATE, so two
// reviewers deciding the same application concurrently produce one winner and
// domain.ErrVerificationVersionConflict for the loser.
// - Exactly one notification per decision. The outbox unique key is
// (application_id, kind), and a repeated decision on an already-decided
// application is a no-op reporting changed=false rather than a second message.
//
// Status transitions are never re-implemented in SQL: they all go through
// domain.CanTransitionVerificationStatus, which the bot dialog validates against
// too. The history table has no UPDATE or DELETE path in this file by design.
type VerificationStore struct {
db sqlcgen.DBTX
}
// NewVerificationStore builds the store on a pgx pool or transaction.
func NewVerificationStore(db sqlcgen.DBTX) *VerificationStore {
return &VerificationStore{db: db}
}
var _ store.VerificationStore = (*VerificationStore)(nil)
const (
defaultVerificationListLimit = 50
maxVerificationListLimit = 200
// maxVerificationUsernameBytes mirrors the octet_length CHECK on
// target_username. The domain request bounds the title but not the username
// snapshot, so the store guards the column itself.
maxVerificationUsernameBytes = 64
// maxVerificationOutboxErrorBytes mirrors the octet_length CHECK on
// verification_notification_outbox.last_error.
maxVerificationOutboxErrorBytes = 1024
)
// Constraint names the store maps onto domain errors. They are the schema's
// invariants, so a race that slips past the pre-checks still reports the same
// error as the check would have.
const (
verificationActiveTargetConstraint = "verification_applications_active_target_idx"
verificationApplicantDraftConstraint = "verification_applications_applicant_draft_idx"
verificationNotificationConstraint = "verification_notification_once"
)
// verificationApplicationColumnList is the application projection shared by every
// reader, in scan order.
const verificationApplicationColumnList = `id, applicant_user_id, target_type, target_id,
target_title, target_username, target_access_hash, category, description,
official_website, social_links, press_links, additional_note, status,
reviewer_admin_id, decision_reason, internal_note, correlation_id,
created_at, updated_at, submitted_at, reviewed_at, version`
// verificationApplicationJoinColumns is the same projection qualified for the
// outbox join, kept in sync by construction rather than by hand.
var verificationApplicationJoinColumns = prefixVerificationColumns("a.")
func prefixVerificationColumns(alias string) string {
parts := strings.Split(verificationApplicationColumnList, ",")
for i, part := range parts {
parts[i] = alias + strings.TrimSpace(part)
}
return strings.Join(parts, ", ")
}
// verificationTxKey carries the transaction driving a decision into the
// applyVerified / clearVerified callback.
type verificationTxKey struct{}
func verificationTxContext(ctx context.Context, tx pgx.Tx) context.Context {
return context.WithValue(ctx, verificationTxKey{}, tx)
}
// VerificationTxFromContext returns the transaction that is currently deciding a
// verification application, if the context came from
// DecideVerificationApplication or RevokeVerification.
//
// This is how "approved implies target verified" is kept atomic across two
// stores: the applyVerified / clearVerified callback must write the peer flag
// through this handle (for example postgres.NewUserStore(tx).SetVerified) instead
// of its own pool connection. A callback that ignores it writes in a separate
// transaction, and the flag would survive a rollback of the decision.
func VerificationTxFromContext(ctx context.Context) (sqlcgen.DBTX, bool) {
tx, ok := ctx.Value(verificationTxKey{}).(pgx.Tx)
if !ok || tx == nil {
return nil, false
}
return tx, true
}
// verificationNow is the single clock for the store. Timestamps are truncated to
// the timestamptz resolution so a value written here reads back identically.
func verificationNow() time.Time {
return time.Now().UTC().Truncate(time.Microsecond)
}
// CreateVerificationDraft opens a draft for the applicant/target pair.
//
// The applicant's own draft wins over everything else: the bot dialog is a single
// conversation and verification_applications_applicant_draft_idx allows exactly
// one, so an applicant who already has one gets it back with created=false and
// the dialog resumes. Only then is the target checked, and an application already
// occupying it reports domain.ErrVerificationApplicationExists.
func (s *VerificationStore) CreateVerificationDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
}
req.TargetTitle = strings.TrimSpace(req.TargetTitle)
req.TargetUsername = domain.NormalizeUsername(req.TargetUsername)
req.CorrelationID = strings.TrimSpace(req.CorrelationID)
req.Draft = req.Draft.Normalize()
if err := validateVerificationDraftRequest(req); err != nil {
return domain.VerificationApplication{}, false, err
}
var app domain.VerificationApplication
created := false
err := withTx(ctx, s.db, "create verification draft", func(tx pgx.Tx) error {
// Both locks are taken before the pre-checks so two concurrent bot dialogs
// serialise instead of racing on the partial unique indexes.
if err := lockVerificationApplicant(ctx, tx, req.ApplicantUserID); err != nil {
return err
}
if err := lockVerificationTarget(ctx, tx, req.TargetType, req.TargetID); err != nil {
return err
}
existing, err := verificationDraftForApplicantTx(ctx, tx, req.ApplicantUserID)
switch {
case err == nil:
app = existing
return nil
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
default:
return err
}
switch _, err := activeVerificationApplicationTx(ctx, tx, req.TargetType, req.TargetID); {
case err == nil:
return domain.ErrVerificationApplicationExists
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
default:
return err
}
now := verificationNow()
inserted, err := scanVerificationApplication(tx.QueryRow(ctx, `
INSERT INTO verification_applications (
applicant_user_id, target_type, target_id, target_title, target_username,
category, description, official_website, social_links, press_links,
additional_note, status, correlation_id, created_at, updated_at, version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'draft',$12,$13,$13,1)
RETURNING `+verificationApplicationColumnList,
req.ApplicantUserID, string(req.TargetType), req.TargetID, req.TargetTitle,
req.TargetUsername, req.Draft.Category, req.Draft.Description,
req.Draft.OfficialWebsite, verificationLinksArg(req.Draft.SocialLinks),
verificationLinksArg(req.Draft.PressLinks), req.Draft.AdditionalNote,
req.CorrelationID, now,
))
if err != nil {
if isUniqueConstraint(err, verificationActiveTargetConstraint) ||
isUniqueConstraint(err, verificationApplicantDraftConstraint) {
return domain.ErrVerificationApplicationExists
}
return fmt.Errorf("insert verification application: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: inserted.ID,
kind: domain.VerificationEventCreated,
to: domain.VerificationStatusDraft,
correlationID: inserted.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
app = inserted
created = true
return nil
})
if err != nil {
return domain.VerificationApplication{}, false, err
}
return app, created, nil
}
// SaveVerificationDraft rewrites the applicant-supplied payload of a draft.
func (s *VerificationStore) SaveVerificationDraft(ctx context.Context, applicationID int64, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if applicationID <= 0 || version <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
}
draft = draft.Normalize()
if err := draft.ValidateDraft(); err != nil {
return domain.VerificationApplication{}, err
}
var app domain.VerificationApplication
err := withTx(ctx, s.db, "save verification draft", func(tx pgx.Tx) error {
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
if err != nil {
return err
}
if current.Version != version {
return domain.ErrVerificationVersionConflict
}
if !current.Editable() {
return domain.ErrVerificationStatusInvalid
}
now := verificationNow()
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
UPDATE verification_applications
SET category = $3,
description = $4,
official_website = $5,
social_links = $6,
press_links = $7,
additional_note = $8,
version = version + 1,
updated_at = GREATEST(updated_at, $9)
WHERE id = $1 AND version = $2
RETURNING `+verificationApplicationColumnList,
applicationID, version, draft.Category, draft.Description,
draft.OfficialWebsite, verificationLinksArg(draft.SocialLinks),
verificationLinksArg(draft.PressLinks), draft.AdditionalNote, now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationVersionConflict
}
if err != nil {
return fmt.Errorf("update verification draft: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: applicationID,
kind: domain.VerificationEventUpdated,
from: current.Status,
to: updated.Status,
correlationID: updated.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
app = updated
return nil
})
if err != nil {
return domain.VerificationApplication{}, err
}
return app, nil
}
// SubmitVerificationApplication moves an application into the review queue and
// stamps submitted_at.
//
// The stored payload is validated against the submission bar here rather than
// only in the bot: an incomplete draft must not be able to reach a reviewer
// through any path. Coming back from in_review (the domain machine allows it)
// releases the claim and keeps the original submitted_at.
func (s *VerificationStore) SubmitVerificationApplication(ctx context.Context, applicationID int64, version int64) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if applicationID <= 0 || version <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
}
var app domain.VerificationApplication
err := withTx(ctx, s.db, "submit verification application", func(tx pgx.Tx) error {
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
if err != nil {
return err
}
if current.Version != version {
return domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusSubmitted) {
return domain.ErrVerificationStatusInvalid
}
if err := verificationDraftOf(current).ValidateForSubmission(); err != nil {
return err
}
now := verificationNow()
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
UPDATE verification_applications
SET status = 'submitted',
reviewer_admin_id = '',
submitted_at = COALESCE(submitted_at, $3),
version = version + 1,
updated_at = GREATEST(updated_at, $3)
WHERE id = $1 AND version = $2
RETURNING `+verificationApplicationColumnList,
applicationID, version, now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationVersionConflict
}
if err != nil {
return fmt.Errorf("submit verification application: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: applicationID,
kind: domain.VerificationEventSubmitted,
from: current.Status,
to: updated.Status,
correlationID: updated.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
app = updated
return nil
})
if err != nil {
return domain.VerificationApplication{}, err
}
return app, nil
}
// CancelVerificationApplication withdraws an active application on the
// applicant's behalf. The reason is the applicant's, so it lands on the history
// row and never in decision_reason, which stays reviewer-owned.
func (s *VerificationStore) CancelVerificationApplication(ctx context.Context, applicationID int64, version int64, reason string) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
reason = strings.TrimSpace(reason)
if applicationID <= 0 || version <= 0 ||
utf8.RuneCountInString(reason) > domain.MaxVerificationReasonLength {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
}
var app domain.VerificationApplication
err := withTx(ctx, s.db, "cancel verification application", func(tx pgx.Tx) error {
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
if err != nil {
return err
}
if current.Version != version {
return domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusCancelled) {
return domain.ErrVerificationStatusInvalid
}
now := verificationNow()
// A cancelled draft was never submitted, but the schema requires
// submitted_at on every non-draft row, so the withdrawal time stands in.
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
UPDATE verification_applications
SET status = 'cancelled',
submitted_at = COALESCE(submitted_at, $3),
version = version + 1,
updated_at = GREATEST(updated_at, $3)
WHERE id = $1 AND version = $2
RETURNING `+verificationApplicationColumnList,
applicationID, version, now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationVersionConflict
}
if err != nil {
return fmt.Errorf("cancel verification application: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: applicationID,
kind: domain.VerificationEventCancelled,
from: current.Status,
to: updated.Status,
reason: reason,
correlationID: updated.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
app = updated
return nil
})
if err != nil {
return domain.VerificationApplication{}, err
}
return app, nil
}
// ClaimVerificationApplication assigns a reviewer and moves the application to
// in_review. reviewed_at stays NULL: a claim is not a decision, and the schema
// pairs reviewed_at with a decided status.
func (s *VerificationStore) ClaimVerificationApplication(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
decision = normalizeVerificationDecision(decision)
if err := decision.Validate(); err != nil {
return domain.VerificationApplication{}, err
}
var app domain.VerificationApplication
err := withTx(ctx, s.db, "claim verification application", func(tx pgx.Tx) error {
current, err := lockVerificationApplicationTx(ctx, tx, decision.ApplicationID)
if err != nil {
return err
}
if current.Version != decision.Version {
return domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusInReview) {
return domain.ErrVerificationStatusInvalid
}
now := verificationNow()
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
UPDATE verification_applications
SET status = 'in_review',
reviewer_admin_id = $3,
version = version + 1,
updated_at = GREATEST(updated_at, $4)
WHERE id = $1 AND version = $2
RETURNING `+verificationApplicationColumnList,
decision.ApplicationID, decision.Version, decision.Reviewer, now,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationVersionConflict
}
if err != nil {
return fmt.Errorf("claim verification application: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: decision.ApplicationID,
kind: domain.VerificationEventClaimed,
from: current.Status,
to: updated.Status,
actor: decision.Reviewer,
reason: decision.Reason,
note: decision.InternalNote,
correlationID: decision.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
app = updated
return nil
})
if err != nil {
return domain.VerificationApplication{}, err
}
return app, nil
}
// DecideVerificationApplication records an approval or a rejection.
//
// Order of checks matters and is the deterministic part of two reviewers acting
// at once: the version is compared first, so the loser of the race always gets
// domain.ErrVerificationVersionConflict rather than a silent no-op. A caller that
// re-issues the same decision with the current version (a retry after it already
// committed) sees the decided application with changed=false, no second history
// row and no second outbox row.
//
// On approve the peer flag is written by applyVerified inside this transaction,
// so a callback error rolls the whole decision back and "approved implies target
// verified" holds even across a crash.
func (s *VerificationStore) DecideVerificationApplication(ctx context.Context, decision domain.VerificationDecision, approve bool, applyVerified func(ctx context.Context, app domain.VerificationApplication) error) (domain.VerificationApplication, bool, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
}
decision = normalizeVerificationDecision(decision)
target := domain.VerificationStatusRejected
kind := domain.VerificationEventRejected
if approve {
target = domain.VerificationStatusApproved
kind = domain.VerificationEventApproved
if err := decision.Validate(); err != nil {
return domain.VerificationApplication{}, false, err
}
if applyVerified == nil {
// Approving without a way to set the flag is exactly the state this
// store exists to make impossible.
return domain.VerificationApplication{}, false, fmt.Errorf("verification approval requires an applyVerified callback")
}
} else if err := decision.ValidateWithReason(); err != nil {
return domain.VerificationApplication{}, false, err
}
var app domain.VerificationApplication
changed := false
err := withTx(ctx, s.db, "decide verification application", func(tx pgx.Tx) error {
current, err := lockVerificationApplicationTx(ctx, tx, decision.ApplicationID)
if err != nil {
return err
}
if current.Version != decision.Version {
return domain.ErrVerificationVersionConflict
}
if current.Status == target {
// Already decided this way: keep the record and the single
// notification, report that nothing moved.
app = current
return nil
}
if !domain.CanTransitionVerificationStatus(current.Status, target) {
return domain.ErrVerificationStatusInvalid
}
now := verificationNow()
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
UPDATE verification_applications
SET status = $3,
reviewer_admin_id = $4,
reviewed_at = $5,
decision_reason = $6,
internal_note = $7,
submitted_at = COALESCE(submitted_at, $5),
version = version + 1,
updated_at = GREATEST(updated_at, $5)
WHERE id = $1 AND version = $2
RETURNING `+verificationApplicationColumnList,
decision.ApplicationID, decision.Version, string(target),
decision.Reviewer, now, decision.Reason, decision.InternalNote,
))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationVersionConflict
}
if err != nil {
return fmt.Errorf("decide verification application: %w", err)
}
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: updated.ID,
kind: kind,
from: current.Status,
to: updated.Status,
actor: decision.Reviewer,
reason: decision.Reason,
note: decision.InternalNote,
correlationID: decision.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
if err := insertVerificationNotificationTx(ctx, tx, updated.ID,
updated.ApplicantUserID, string(target), now); err != nil {
return err
}
if approve {
if err := applyVerified(verificationTxContext(ctx, tx), updated); err != nil {
return err
}
}
app = updated
changed = true
return nil
})
if err != nil {
return domain.VerificationApplication{}, false, err
}
return app, changed, nil
}
// RevokeVerification clears the platform flag of a previously approved target.
//
// The application stays approved: it is the audit record of a decision that did
// happen. The revocation is its own history row plus its own outbox kind, and
// that outbox row is what makes a repeated revocation a no-op with changed=false.
//
// A target with no approved application on file (verified by an earlier
// deployment, or by an operator by hand) is still cleared through the callback,
// because leaving a flag standing is worse than a missing audit row; there is
// then nothing to deduplicate against, so such a call always reports
// changed=true.
func (s *VerificationStore) RevokeVerification(ctx context.Context, req domain.VerificationRevocation, clearVerified func(ctx context.Context, target domain.Peer) error) (domain.VerificationApplication, bool, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
}
req.Reviewer = strings.TrimSpace(req.Reviewer)
req.Reason = strings.TrimSpace(req.Reason)
req.InternalNote = strings.TrimSpace(req.InternalNote)
req.CorrelationID = strings.TrimSpace(req.CorrelationID)
if err := req.Validate(); err != nil {
return domain.VerificationApplication{}, false, err
}
if len(req.CorrelationID) > domain.MaxVerificationCorrelationLen {
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationInvalid
}
if clearVerified == nil {
return domain.VerificationApplication{}, false, fmt.Errorf("verification revocation requires a clearVerified callback")
}
var app domain.VerificationApplication
changed := false
err := withTx(ctx, s.db, "revoke verification", func(tx pgx.Tx) error {
// There may be no application row to lock, so the target itself is the
// serialisation point for concurrent revocations.
if err := lockVerificationTarget(ctx, tx, req.TargetType, req.TargetID); err != nil {
return err
}
var current domain.VerificationApplication
switch found, err := newestApprovedVerificationApplicationTx(ctx, tx, req.TargetType, req.TargetID); {
case err == nil:
current = found
done, err := verificationNotificationExistsTx(ctx, tx, current.ID, string(domain.VerificationEventRevoked))
if err != nil {
return err
}
if done {
app = current
return nil
}
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
default:
return err
}
if err := clearVerified(verificationTxContext(ctx, tx),
domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}); err != nil {
return err
}
if current.ID != 0 {
now := verificationNow()
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: current.ID,
kind: domain.VerificationEventRevoked,
from: current.Status,
to: current.Status,
actor: req.Reviewer,
reason: req.Reason,
note: req.InternalNote,
correlationID: req.CorrelationID,
createdAt: now,
}); err != nil {
return err
}
if err := insertVerificationNotificationTx(ctx, tx, current.ID,
current.ApplicantUserID, string(domain.VerificationEventRevoked), now); err != nil {
return err
}
}
app = current
changed = true
return nil
})
if err != nil {
return domain.VerificationApplication{}, false, err
}
return app, changed, nil
}
// VerificationApplication reads one application by id.
func (s *VerificationStore) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if applicationID <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return verificationApplicationByID(ctx, s.db, applicationID)
}
// ActiveVerificationApplicationForTarget returns the live application occupying a
// target, if any.
func (s *VerificationStore) ActiveVerificationApplicationForTarget(ctx context.Context, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if !target.Valid() || targetID <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationTargetInvalid
}
return activeVerificationApplicationTx(ctx, s.db, target, targetID)
}
// VerificationDraftForApplicant returns the applicant's open draft, if any.
func (s *VerificationStore) VerificationDraftForApplicant(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if applicantUserID <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return verificationDraftForApplicantTx(ctx, s.db, applicantUserID)
}
// ListVerificationApplications is the review-queue query.
//
// Paging is keyset over (created_at DESC, id DESC), the order
// verification_applications_queue_idx is built for: filter.Until and
// filter.BeforeID carry the last row of the previous page, filter.CreatedAt is
// the inclusive lower bound of the date range. Query matches an application id or
// a peer id when it is numeric and otherwise prefix-matches
// lower(target_username), which is what verification_applications_username_idx
// indexes.
func (s *VerificationStore) ListVerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("verification store is not configured")
}
statuses, err := verificationFilterStatuses(filter)
if err != nil {
return nil, err
}
if filter.TargetType != "" && !filter.TargetType.Valid() {
return nil, domain.ErrVerificationTargetInvalid
}
if len(filter.Reviewer) > domain.MaxVerificationReviewerLength {
return nil, domain.ErrVerificationApplicationInvalid
}
limit := verificationLimit(filter.Limit)
numeric, isNumeric, prefix := parseVerificationQuery(filter.Query)
var since, until any
if !filter.CreatedAt.IsZero() {
since = filter.CreatedAt.UTC()
}
if !filter.Until.IsZero() {
until = filter.Until.UTC()
}
rows, err := s.db.Query(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE (cardinality($1::text[]) = 0 OR status = ANY($1::text[]))
AND ($2 = '' OR target_type = $2)
AND ($3 = '' OR reviewer_admin_id = $3)
AND ($4::timestamptz IS NULL OR created_at >= $4::timestamptz)
AND ($5::timestamptz IS NULL OR (created_at, id) < ($5::timestamptz, $6))
AND ($5::timestamptz IS NOT NULL OR $6 = 0 OR id < $6)
AND (
NOT $7::boolean
OR ($8::boolean AND (id = $9::bigint OR target_id = $9::bigint))
OR (
NOT $8::boolean
AND target_username <> ''
AND lower(target_username) LIKE $10::text || '%'
)
)
ORDER BY created_at DESC, id DESC
LIMIT $11`,
statuses, string(filter.TargetType), filter.Reviewer, since, until,
filter.BeforeID, prefix != "" || isNumeric, isNumeric, numeric,
escapeLike(prefix), limit,
)
if err != nil {
return nil, fmt.Errorf("list verification applications: %w", err)
}
defer rows.Close()
out := make([]domain.VerificationApplication, 0, limit)
for rows.Next() {
app, err := scanVerificationApplication(rows)
if err != nil {
return nil, fmt.Errorf("scan verification application: %w", err)
}
out = append(out, app)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate verification applications: %w", err)
}
return out, nil
}
// VerificationApplicationsForApplicant returns the applicant's own history,
// newest first, for the bot's /status command.
func (s *VerificationStore) VerificationApplicationsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("verification store is not configured")
}
if applicantUserID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
limit = verificationLimit(limit)
rows, err := s.db.Query(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE applicant_user_id = $1
ORDER BY id DESC
LIMIT $2`, applicantUserID, limit)
if err != nil {
return nil, fmt.Errorf("list applicant verification applications: %w", err)
}
defer rows.Close()
out := make([]domain.VerificationApplication, 0, limit)
for rows.Next() {
app, err := scanVerificationApplication(rows)
if err != nil {
return nil, fmt.Errorf("scan applicant verification application: %w", err)
}
out = append(out, app)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate applicant verification applications: %w", err)
}
return out, nil
}
// VerificationStatusCounts is the queue summary. Statuses nobody is in are absent
// rather than zero, which a map read cannot tell apart anyway.
func (s *VerificationStore) VerificationStatusCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("verification store is not configured")
}
rows, err := s.db.Query(ctx, `
SELECT status, count(*)
FROM verification_applications
GROUP BY status`)
if err != nil {
return nil, fmt.Errorf("count verification applications: %w", err)
}
defer rows.Close()
out := make(domain.VerificationStatusCounts, 6)
for rows.Next() {
var status string
var count int64
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("scan verification status count: %w", err)
}
out[domain.VerificationStatus(status)] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate verification status counts: %w", err)
}
return out, nil
}
// VerificationApplicationEvents returns the immutable history, newest first.
func (s *VerificationStore) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("verification store is not configured")
}
if applicationID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
limit = verificationLimit(limit)
rows, err := s.db.Query(ctx, `
SELECT id, application_id, kind, from_status, to_status, actor, reason, note,
correlation_id, created_at
FROM verification_application_events
WHERE application_id = $1
ORDER BY id DESC
LIMIT $2`, applicationID, limit)
if err != nil {
return nil, fmt.Errorf("list verification application events: %w", err)
}
defer rows.Close()
out := make([]domain.VerificationApplicationEvent, 0, limit)
for rows.Next() {
var event domain.VerificationApplicationEvent
var kind, from, to string
if err := rows.Scan(&event.ID, &event.ApplicationID, &kind, &from, &to,
&event.Actor, &event.Reason, &event.Note, &event.CorrelationID,
&event.CreatedAt); err != nil {
return nil, fmt.Errorf("scan verification application event: %w", err)
}
event.Kind = domain.VerificationApplicationEventKind(kind)
event.FromStatus = domain.VerificationStatus(from)
event.ToStatus = domain.VerificationStatus(to)
event.CreatedAt = event.CreatedAt.UTC()
out = append(out, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate verification application events: %w", err)
}
return out, nil
}
// LastVerificationRejection returns the newest rejected application for the
// applicant/target pair, which is what the re-application cooldown is measured
// from.
func (s *VerificationStore) LastVerificationRejection(ctx context.Context, applicantUserID int64, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
if s == nil || s.db == nil {
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
}
if applicantUserID <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
}
if !target.Valid() || targetID <= 0 {
return domain.VerificationApplication{}, domain.ErrVerificationTargetInvalid
}
app, err := scanVerificationApplication(s.db.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE applicant_user_id = $1 AND target_type = $2 AND target_id = $3
AND status = 'rejected'
ORDER BY reviewed_at DESC NULLS LAST, id DESC
LIMIT 1`, applicantUserID, string(target), targetID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("get last verification rejection: %w", err)
}
return app, nil
}
// PendingVerificationNotifications returns undelivered outbox rows, oldest first.
// The application travels with the row because the worker renders the message
// text from it and must not need a second round trip per notification.
func (s *VerificationStore) PendingVerificationNotifications(ctx context.Context, limit int) ([]store.VerificationNotification, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("verification store is not configured")
}
limit = verificationLimit(limit)
rows, err := s.db.Query(ctx, `
SELECT o.id, o.application_id, o.recipient_user_id, o.kind, o.attempts,
`+verificationApplicationJoinColumns+`
FROM verification_notification_outbox o
JOIN verification_applications a ON a.id = o.application_id
WHERE o.delivered_at IS NULL
ORDER BY o.created_at, o.id
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("list pending verification notifications: %w", err)
}
defer rows.Close()
out := make([]store.VerificationNotification, 0, limit)
for rows.Next() {
var item store.VerificationNotification
var row verificationApplicationRow
dest := append([]any{&item.ID, &item.ApplicationID, &item.RecipientUserID,
&item.Kind, &item.Attempts}, row.dest()...)
if err := rows.Scan(dest...); err != nil {
return nil, fmt.Errorf("scan pending verification notification: %w", err)
}
item.Application = row.application()
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending verification notifications: %w", err)
}
return out, nil
}
// MarkVerificationNotificationDelivered closes an outbox row and appends the
// 'notified' history entry, so the application timeline records that the
// applicant was actually told. Closing an already-closed row is a no-op: the
// outbox is exactly-once, not at-least-once.
func (s *VerificationStore) MarkVerificationNotificationDelivered(ctx context.Context, id int64) error {
if s == nil || s.db == nil {
return fmt.Errorf("verification store is not configured")
}
if id <= 0 {
return domain.ErrVerificationApplicationInvalid
}
return withTx(ctx, s.db, "deliver verification notification", func(tx pgx.Tx) error {
var applicationID int64
var kind string
var deliveredAt *time.Time
err := tx.QueryRow(ctx, `
SELECT application_id, kind, delivered_at
FROM verification_notification_outbox
WHERE id = $1
FOR UPDATE`, id).Scan(&applicationID, &kind, &deliveredAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationApplicationNotFound
}
if err != nil {
return fmt.Errorf("lock verification notification: %w", err)
}
if deliveredAt != nil {
return nil
}
now := verificationNow()
if _, err := tx.Exec(ctx, `
UPDATE verification_notification_outbox
SET delivered_at = $2, last_error = ''
WHERE id = $1`, id, now); err != nil {
return fmt.Errorf("deliver verification notification: %w", err)
}
app, err := verificationApplicationByID(ctx, tx, applicationID)
if err != nil {
return err
}
// reason carries the notification kind so the timeline says which message
// the applicant received.
return insertVerificationEventTx(ctx, tx, verificationEventInput{
applicationID: applicationID,
kind: domain.VerificationEventNotified,
from: app.Status,
to: app.Status,
reason: kind,
correlationID: app.CorrelationID,
createdAt: now,
})
})
}
// MarkVerificationNotificationFailed records a delivery attempt. The row stays
// pending, so a poisoned notification keeps its attempt count and last error
// instead of spinning without a trace.
func (s *VerificationStore) MarkVerificationNotificationFailed(ctx context.Context, id int64, reason string) error {
if s == nil || s.db == nil {
return fmt.Errorf("verification store is not configured")
}
if id <= 0 {
return domain.ErrVerificationApplicationInvalid
}
tag, err := s.db.Exec(ctx, `
UPDATE verification_notification_outbox
SET attempts = attempts + 1, last_error = $2
WHERE id = $1 AND delivered_at IS NULL`,
id, truncateVerificationError(reason))
if err != nil {
return fmt.Errorf("fail verification notification: %w", err)
}
if tag.RowsAffected() == 0 {
// Either the row is gone or it is already delivered; both mean there is no
// pending attempt left to record.
var exists bool
if err := s.db.QueryRow(ctx, `
SELECT true FROM verification_notification_outbox WHERE id = $1`, id).Scan(&exists); errors.Is(err, pgx.ErrNoRows) {
return domain.ErrVerificationApplicationNotFound
} else if err != nil {
return fmt.Errorf("get verification notification: %w", err)
}
}
return nil
}
// ---- helpers ----------------------------------------------------------------
// verificationApplicationRow adapts the projection onto the domain type: the
// enum columns arrive as text and submitted_at / reviewed_at are nullable.
type verificationApplicationRow struct {
app domain.VerificationApplication
targetType string
status string
submittedAt *time.Time
reviewedAt *time.Time
}
func (r *verificationApplicationRow) dest() []any {
return []any{
&r.app.ID, &r.app.ApplicantUserID, &r.targetType, &r.app.TargetID,
&r.app.TargetTitle, &r.app.TargetUsername, &r.app.TargetAccessHash,
&r.app.Category, &r.app.Description, &r.app.OfficialWebsite,
&r.app.SocialLinks, &r.app.PressLinks, &r.app.AdditionalNote, &r.status,
&r.app.ReviewerAdminID, &r.app.DecisionReason, &r.app.InternalNote,
&r.app.CorrelationID, &r.app.CreatedAt, &r.app.UpdatedAt,
&r.submittedAt, &r.reviewedAt, &r.app.Version,
}
}
func (r *verificationApplicationRow) application() domain.VerificationApplication {
app := r.app
app.TargetType = domain.VerificationTargetType(r.targetType)
app.Status = domain.VerificationStatus(r.status)
app.CreatedAt = app.CreatedAt.UTC()
app.UpdatedAt = app.UpdatedAt.UTC()
if r.submittedAt != nil {
app.SubmittedAt = r.submittedAt.UTC()
}
if r.reviewedAt != nil {
app.ReviewedAt = r.reviewedAt.UTC()
}
// An empty text[] and a never-filled list must not read back differently
// between the two backends.
if len(app.SocialLinks) == 0 {
app.SocialLinks = nil
}
if len(app.PressLinks) == 0 {
app.PressLinks = nil
}
return app
}
func scanVerificationApplication(row pgx.Row) (domain.VerificationApplication, error) {
var r verificationApplicationRow
if err := row.Scan(r.dest()...); err != nil {
return domain.VerificationApplication{}, err
}
return r.application(), nil
}
func verificationApplicationByID(ctx context.Context, db sqlcgen.DBTX, applicationID int64) (domain.VerificationApplication, error) {
app, err := scanVerificationApplication(db.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE id = $1`, applicationID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("get verification application: %w", err)
}
return app, nil
}
// lockVerificationApplicationTx reads the application for mutation. FOR UPDATE
// plus the version guard on the following UPDATE is what serialises two
// reviewers: the second one blocks here and then sees the bumped version.
func lockVerificationApplicationTx(ctx context.Context, tx pgx.Tx, applicationID int64) (domain.VerificationApplication, error) {
app, err := scanVerificationApplication(tx.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE id = $1
FOR UPDATE`, applicationID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("lock verification application: %w", err)
}
return app, nil
}
// activeVerificationApplicationTx resolves the application occupying a target.
// verification_applications_active_target_idx guarantees there is at most one, so
// no ordering is needed to pick it.
func activeVerificationApplicationTx(ctx context.Context, db sqlcgen.DBTX, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
app, err := scanVerificationApplication(db.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE target_type = $1 AND target_id = $2
AND status IN ('draft', 'submitted', 'in_review')
LIMIT 1`, string(target), targetID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("get active verification application: %w", err)
}
return app, nil
}
func verificationDraftForApplicantTx(ctx context.Context, db sqlcgen.DBTX, applicantUserID int64) (domain.VerificationApplication, error) {
app, err := scanVerificationApplication(db.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE applicant_user_id = $1 AND status = 'draft'
LIMIT 1`, applicantUserID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("get verification draft: %w", err)
}
return app, nil
}
func newestApprovedVerificationApplicationTx(ctx context.Context, tx pgx.Tx, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
app, err := scanVerificationApplication(tx.QueryRow(ctx, `
SELECT `+verificationApplicationColumnList+`
FROM verification_applications
WHERE target_type = $1 AND target_id = $2 AND status = 'approved'
ORDER BY reviewed_at DESC NULLS LAST, id DESC
LIMIT 1
FOR UPDATE`, string(target), targetID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if err != nil {
return domain.VerificationApplication{}, fmt.Errorf("lock approved verification application: %w", err)
}
return app, nil
}
type verificationEventInput struct {
applicationID int64
kind domain.VerificationApplicationEventKind
from domain.VerificationStatus
to domain.VerificationStatus
actor string
reason string
note string
correlationID string
createdAt time.Time
}
// insertVerificationEventTx appends one history row. There is deliberately no
// update or delete counterpart: the timeline is the audit trail.
func insertVerificationEventTx(ctx context.Context, tx pgx.Tx, in verificationEventInput) error {
if !in.kind.Valid() {
return domain.ErrVerificationApplicationInvalid
}
if _, err := tx.Exec(ctx, `
INSERT INTO verification_application_events (
application_id, kind, from_status, to_status, actor, reason, note,
correlation_id, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
in.applicationID, string(in.kind), string(in.from), string(in.to),
in.actor, in.reason, in.note, in.correlationID, in.createdAt,
); err != nil {
return fmt.Errorf("insert verification application event: %w", err)
}
return nil
}
// insertVerificationNotificationTx enqueues the applicant notification. The
// unique key is the decision, not the attempt, so a retry that reaches this far
// cannot produce a second message.
func insertVerificationNotificationTx(ctx context.Context, tx pgx.Tx, applicationID, recipientUserID int64, kind string, now time.Time) error {
if _, err := tx.Exec(ctx, `
INSERT INTO verification_notification_outbox (
application_id, recipient_user_id, kind, created_at
) VALUES ($1,$2,$3,$4)
ON CONFLICT ON CONSTRAINT `+verificationNotificationConstraint+` DO NOTHING`,
applicationID, recipientUserID, kind, now,
); err != nil {
return fmt.Errorf("enqueue verification notification: %w", err)
}
return nil
}
func verificationNotificationExistsTx(ctx context.Context, tx pgx.Tx, applicationID int64, kind string) (bool, error) {
var exists bool
err := tx.QueryRow(ctx, `
SELECT true FROM verification_notification_outbox
WHERE application_id = $1 AND kind = $2`, applicationID, kind).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("get verification notification: %w", err)
}
return exists, nil
}
func lockVerificationApplicant(ctx context.Context, tx pgx.Tx, applicantUserID int64) error {
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
fmt.Sprintf("verification-applicant:%d", applicantUserID)); err != nil {
return fmt.Errorf("lock verification applicant: %w", err)
}
return nil
}
func lockVerificationTarget(ctx context.Context, tx pgx.Tx, target domain.VerificationTargetType, targetID int64) error {
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
fmt.Sprintf("verification-target:%s:%d", target, targetID)); err != nil {
return fmt.Errorf("lock verification target: %w", err)
}
return nil
}
// validateVerificationDraftRequest checks a draft-creation request.
//
// It deliberately does not run SubmitVerificationApplicationRequest.Validate: a
// draft is opened before the bot has collected anything, so only the fields
// already present must be well formed. The submission bar is enforced by
// SubmitVerificationApplication.
func validateVerificationDraftRequest(req domain.SubmitVerificationApplicationRequest) error {
if req.ApplicantUserID <= 0 || req.TargetID <= 0 || !req.TargetType.Valid() {
return domain.ErrVerificationApplicationInvalid
}
if utf8.RuneCountInString(req.TargetTitle) > domain.MaxVerificationTitleLength {
return domain.ErrVerificationApplicationInvalid
}
if len(req.TargetUsername) > maxVerificationUsernameBytes {
return domain.ErrVerificationApplicationInvalid
}
if len(req.CorrelationID) > domain.MaxVerificationCorrelationLen {
return domain.ErrVerificationApplicationInvalid
}
return req.Draft.ValidateDraft()
}
// verificationDraftOf projects the stored payload back onto the applicant input,
// so submission is validated against the same domain rules the bot dialog used.
func verificationDraftOf(app domain.VerificationApplication) domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: app.Category,
Description: app.Description,
OfficialWebsite: app.OfficialWebsite,
SocialLinks: app.SocialLinks,
PressLinks: app.PressLinks,
AdditionalNote: app.AdditionalNote,
}
}
func normalizeVerificationDecision(decision domain.VerificationDecision) domain.VerificationDecision {
decision.Reviewer = strings.TrimSpace(decision.Reviewer)
decision.Reason = strings.TrimSpace(decision.Reason)
decision.InternalNote = strings.TrimSpace(decision.InternalNote)
decision.CorrelationID = strings.TrimSpace(decision.CorrelationID)
return decision
}
// verificationLinksArg keeps a NOT NULL text[] out of NULL: pgx encodes a nil
// slice as NULL, which the column rejects.
func verificationLinksArg(links []string) []string {
if links == nil {
return []string{}
}
return links
}
func verificationLimit(limit int) int {
if limit <= 0 {
return defaultVerificationListLimit
}
if limit > maxVerificationListLimit {
return maxVerificationListLimit
}
return limit
}
func verificationFilterStatuses(filter domain.VerificationApplicationFilter) ([]string, error) {
statuses := make([]string, 0, len(filter.Statuses))
for _, status := range filter.Statuses {
if !status.Valid() {
return nil, domain.ErrVerificationApplicationInvalid
}
statuses = append(statuses, string(status))
}
return statuses, nil
}
// parseVerificationQuery splits the review-queue search term into its two
// shapes: a number addresses an application id or a peer id, anything else is a
// username prefix. Telegram usernames never start with a digit, so the two
// shapes cannot collide.
func parseVerificationQuery(query string) (numeric int64, isNumeric bool, prefix string) {
query = strings.TrimSpace(query)
query = strings.TrimPrefix(query, "@")
query = strings.TrimSpace(query)
if query == "" {
return 0, false, ""
}
if id, err := strconv.ParseInt(query, 10, 64); err == nil && id > 0 {
return id, true, ""
}
return 0, false, strings.ToLower(query)
}
// truncateVerificationError bounds the outbox error text at the column CHECK,
// on a rune boundary, because a worker hands over whatever the transport said.
func truncateVerificationError(reason string) string {
if len(reason) <= maxVerificationOutboxErrorBytes {
return reason
}
cut := maxVerificationOutboxErrorBytes
for cut > 0 && !utf8.ValidString(reason[:cut]) {
cut--
}
return reason[:cut]
}