Files
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

623 lines
24 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"go.temporal.io/sdk/temporal"
)
// Activities holds dependencies for FedWiki domain activities.
type Activities struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
Database *sql.DB
// Registry is the domains registry: the authority for every name FedWiki
// serves. Allocation, release, and servability writes go through it
// (design D8). Nil in tests that exercise no name-bearing path; every
// registry touch below is nil-guarded so those keep working.
Registry *domains.Registry
Logger *slog.Logger
Client *FarmManagerClient
FedWikiAllowedDomains []string // Domains where users can create sites
SupportURL string
}
// ActivitiesConfig holds configuration for creating FedWiki activities.
type ActivitiesConfig struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
Database *sql.DB
Registry *domains.Registry
Logger *slog.Logger
FedWikiFarmAPIURL string // URL for FarmManager API calls
FedWikiAllowedDomains []string // Domains where users can create sites
FedWikiAdminToken string
SupportURL string
}
// NewActivities creates a new Activities instance with the FarmManager client.
func NewActivities(cfg ActivitiesConfig) *Activities {
client := NewFarmManagerClient(cfg.FedWikiFarmAPIURL, cfg.FedWikiAdminToken)
if cfg.Logger != nil {
cfg.Logger.Info("FedWiki activities initialized",
slog.String("farmAPIURL", cfg.FedWikiFarmAPIURL),
slog.Any("allowedDomains", cfg.FedWikiAllowedDomains),
slog.Bool("hasToken", cfg.FedWikiAdminToken != ""))
}
return &Activities{
SiteQ: cfg.SiteQ,
EntitlementsQ: cfg.EntitlementsQ,
Database: cfg.Database,
Registry: cfg.Registry,
Logger: cfg.Logger,
Client: client,
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
SupportURL: cfg.SupportURL,
}
}
// resolveFullDomain builds the site's full DNS name from a create request,
// falling back to the first configured farm domain for a hosted site with no
// explicit parent. Shared by AllocateDomainActivity and
// CreateFedWikiSiteActivity so the reserved placement's fqdn and the
// `fedwiki.sites` row's domain can never disagree about the name.
func (a *Activities) resolveFullDomain(domain, siteDomain string, isCustomDomain bool) (string, error) {
if siteDomain == "" && !isCustomDomain {
if len(a.FedWikiAllowedDomains) == 0 {
return "", temporal.NewNonRetryableApplicationError(
"No allowed domains configured for site creation",
"ConfigurationError",
nil,
)
}
siteDomain = a.FedWikiAllowedDomains[0]
}
return BuildFullDomain(domain, siteDomain, isCustomDomain), nil
}
// AllocateDomainInput is the input for the AllocateDomain activity. It mirrors
// the create request's name fields rather than a pre-built FQDN so the
// allowed-domains fallback stays in one place (resolveFullDomain).
type AllocateDomainInput struct {
// SiteID is the id to reserve the name against. It comes from the
// workflow, not from this activity, because the activity is at-least-once:
// an id minted here would differ between attempts, and a retry after a
// commit whose result was lost would collide with its own first placement.
SiteID string
WorkspaceID string
Domain string // The subdomain part (e.g., "mysite"), or the full name for a custom domain
SiteDomain string // The parent domain (e.g., "localtest.me") - ignored for custom domains
IsCustomDomain bool
}
// AllocateDomainOutput carries the reservation: the site id the placement is
// bound to, and the full domain it was reserved at.
type AllocateDomainOutput struct {
SiteID string
Domain string
}
// AllocateDomainActivity reserves the site's name in the domains registry
// before any farm call (design D8): one transaction ensures the claim
// (auto-carving a member claim for a hosted label) and inserts the placement
// against the workflow's site id, so the name is authoritatively held across
// the farm retry window. Namespace refusals are permanent — a name someone
// else holds will not free itself mid-retry — so they surface as
// non-retryable, with the collapsed member-facing message (never the internal
// verdict, which would make the console an oracle for who holds a name).
//
// Re-running it against a reservation it already made is a no-op that returns
// the same output, so a lost result costs nothing but a second attempt.
func (a *Activities) AllocateDomainActivity(ctx context.Context, input AllocateDomainInput) (*AllocateDomainOutput, error) {
fullDomain, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
if err != nil {
return nil, err
}
if a.Registry == nil {
return nil, temporal.NewNonRetryableApplicationError(
"Domain allocation is not available",
"ConfigurationError",
nil,
)
}
if input.SiteID == "" {
// Retry-safety depends on the id being the workflow's; minting a
// fallback here would reintroduce exactly the collision it prevents.
return nil, temporal.NewNonRetryableApplicationError(
"Domain allocation is not available",
"ConfigurationError",
nil,
)
}
siteID := input.SiteID
if err := AllocateSiteDomain(ctx, a.Registry, input.WorkspaceID, fullDomain, siteID); err != nil {
a.Logger.Warn("failed to allocate site domain",
slog.String("workspaceID", input.WorkspaceID),
slog.String("domain", fullDomain),
slog.Any("error", err))
if msg := allocationRefusal(err); msg != "" {
return nil, temporal.NewNonRetryableApplicationError(msg, "InvalidDomain", err)
}
return nil, fmt.Errorf("failed to allocate site domain %s: %w", fullDomain, err)
}
a.Logger.Info("site domain allocated",
slog.String("siteID", siteID),
slog.String("domain", fullDomain))
return &AllocateDomainOutput{SiteID: siteID, Domain: fullDomain}, nil
}
// allocationRefusal maps a registry error onto the member-facing message for a
// permanent refusal, or "" when the failure was transient (a database blip,
// say) and retrying is right. ErrNameUnavailable collapses to the registry's
// own no-reason message; the structural refusals get their own copy because
// they describe what the member must do next, not who holds what.
func allocationRefusal(err error) string {
var unavailable *domains.Unavailable
switch {
case errors.As(err, &unavailable):
return unavailable.MemberMessage()
case errors.Is(err, domains.ErrInvalidName):
return "That domain name is not valid. Please check it and try again."
case errors.Is(err, domains.ErrNameUnavailable):
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
case errors.Is(err, domains.ErrNoLiveClaim), errors.Is(err, domains.ErrClaimRequired):
return "You need to verify that you control this domain before creating a site on it."
case errors.Is(err, domains.ErrForeignClaim), errors.Is(err, domains.ErrClaimNotActive):
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
default:
return ""
}
}
// ReleaseDomainInput is the input for the ReleaseDomain activity: the site id
// the placement was reserved against, which is the registry's resource ref.
type ReleaseDomainInput struct {
SiteID string
}
// ReleaseDomainActivity frees a reserved name — the saga's compensation for a
// farm-side create failure. Idempotent (an already-released placement is not
// an error), so Temporal's retries and the boot reconciliation backstop can
// both run over the same reservation without conflict.
func (a *Activities) ReleaseDomainActivity(ctx context.Context, input ReleaseDomainInput) error {
if a.Registry == nil || input.SiteID == "" {
return nil
}
result, err := ReleaseSiteDomain(ctx, a.Registry, input.SiteID)
if err != nil {
return fmt.Errorf("release site domain for %s: %w", input.SiteID, err)
}
a.Logger.Info("site domain released",
slog.String("siteID", input.SiteID),
slog.Bool("placementFound", result.Found),
slog.Bool("claimReleased", result.ClaimReleased))
return nil
}
// CreateFedWikiSiteInput is the input for the CreateFedWikiSite activity.
type CreateFedWikiSiteInput struct {
// SiteID is the id AllocateDomainActivity already reserved the name
// against; the `fedwiki.sites` row must be inserted with it so the
// placement's resource_ref resolves (design D8). Never blank on the saga
// path — the workflow allocates before it calls this.
SiteID string
WorkspaceID string
Domain string // The subdomain part (e.g., "mysite")
SiteDomain string // The parent domain (e.g., "localtest.me") - ignored for custom domains
OwnerName string // Display name for the owner
OwnerID string // OIDC subject (OAuth2 ID) for owner verification
IsCustomDomain bool
}
// CreateFedWikiSiteOutput is the output for the CreateFedWikiSite activity.
type CreateFedWikiSiteOutput struct {
SiteID string
Domain string
}
// CreateFedWikiSiteActivity creates a new site on the FedWiki farm and records it in the database.
func (a *Activities) CreateFedWikiSiteActivity(ctx context.Context, input CreateFedWikiSiteInput) (*CreateFedWikiSiteOutput, error) {
if input.SiteID == "" {
// The registry reserved the name against a specific id before this
// activity ran; inserting a different one would orphan the placement.
return nil, temporal.NewNonRetryableApplicationError(
"Site creation was not allocated a domain reservation",
"InvalidDomain",
nil,
)
}
// Build the full domain for FarmManager API and storage — the same
// resolution AllocateDomainActivity used, so the row shadows the placement.
fullDomain, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
if err != nil {
return nil, err
}
a.Logger.Info("creating FedWiki site",
slog.String("workspaceID", input.WorkspaceID),
slog.String("domain", fullDomain),
slog.String("ownerName", input.OwnerName),
slog.String("ownerID", input.OwnerID))
// Call the FarmManager API to create the site
if _, err := a.Client.CreateSite(ctx, fullDomain, input.OwnerName, input.OwnerID); err != nil {
a.Logger.Error("failed to create site via FarmManager API",
slog.String("domain", fullDomain),
slog.Any("error", err))
// Check if this is a non-retryable error (e.g., 409 Conflict)
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
"FarmManagerAPIError",
err,
)
}
return nil, fmt.Errorf("failed to create site on FedWiki farm: %w", err)
}
// Record the site in our database with the full domain, under the id
// AllocateDomainActivity already reserved the name against: site_id is no
// longer defaulted by Postgres, because the placement that holds the name
// points at this id and was written before the row existed (design D8).
site, err := a.SiteQ.CreateSite(ctx, fwmod.CreateSiteParams{
SiteID: input.SiteID,
WorkspaceID: input.WorkspaceID,
Domain: fullDomain,
})
if err != nil {
a.Logger.Error("failed to record site in database",
slog.String("domain", fullDomain),
slog.Any("error", err))
return nil, fmt.Errorf("site created on FedWiki but failed to record locally: %w", err)
}
a.Logger.Info("FedWiki site created successfully",
slog.String("siteID", site.SiteID),
slog.String("domain", fullDomain))
return &CreateFedWikiSiteOutput{
SiteID: site.SiteID,
Domain: site.Domain,
}, nil
}
// DeleteFedWikiSiteInput is the input for the DeleteFedWikiSite activity.
type DeleteFedWikiSiteInput struct {
Domain string // Full domain as stored in database
}
// DeleteFedWikiSiteOutput is the output for the DeleteFedWikiSite activity.
type DeleteFedWikiSiteOutput struct {
Success bool
}
// DeleteFedWikiSiteActivity deletes a site from the FedWiki farm and removes it from the database.
func (a *Activities) DeleteFedWikiSiteActivity(ctx context.Context, input DeleteFedWikiSiteInput) (*DeleteFedWikiSiteOutput, error) {
a.Logger.Info("deleting FedWiki site",
slog.String("domain", input.Domain))
// Read the site id BEFORE the row goes away: the registry addresses
// placements by resource ref, and after DeleteSiteByDomain there is
// nothing left to derive it from. A missing row is not fatal — this
// activity is at-least-once, so a retry that finds no row is one whose
// earlier attempt already deleted it, and the release below already ran.
var siteID string
if site, err := a.SiteQ.GetSiteByDomain(ctx, input.Domain); err == nil {
siteID = site.SiteID
} else if !errors.Is(err, sql.ErrNoRows) {
a.Logger.Warn("failed to read site before delete",
slog.String("domain", input.Domain),
slog.Any("error", err))
}
// Call the FarmManager API to delete the site (hard delete)
_, err := a.Client.HardDeleteSite(ctx, input.Domain)
if err != nil {
a.Logger.Error("failed to delete site via FarmManager API",
slog.String("domain", input.Domain),
slog.Any("error", err))
// Check if this is a non-retryable error (e.g., 403 Forbidden, 401 Unauthorized)
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
"FarmManagerAPIError",
err,
)
}
return nil, fmt.Errorf("failed to delete site on FedWiki farm: %w", err)
}
// Free the name BEFORE the row that names it: a permanent delete releases
// the placement and, when this was the carved claim's last occupant, the
// claim with it — so the label becomes available again, matching
// delete-frees-the-domain.
//
// Order is load-bearing. Deleting the row first and releasing after would
// leave a retry (the release failed, the activity re-runs) with no row to
// read the site id from, so it would skip the release and strand the
// placement forever. Releasing first makes every prefix of this activity
// recoverable: the release is idempotent, and a row still present with its
// name already freed is re-adopted by boot reconciliation.
if a.Registry != nil && siteID != "" {
if err := a.ReleaseDomainActivity(ctx, ReleaseDomainInput{SiteID: siteID}); err != nil {
a.Logger.Error("failed to release site placement",
slog.String("domain", input.Domain),
slog.Any("error", err))
return nil, fmt.Errorf("site deleted on FedWiki but failed to release its name: %w", err)
}
}
// Remove the site from our database
err = a.SiteQ.DeleteSiteByDomain(ctx, input.Domain)
if err != nil {
a.Logger.Error("failed to remove site from database",
slog.String("domain", input.Domain),
slog.Any("error", err))
return nil, fmt.Errorf("site deleted on FedWiki but failed to remove locally: %w", err)
}
a.Logger.Info("FedWiki site deleted successfully",
slog.String("domain", input.Domain))
return &DeleteFedWikiSiteOutput{Success: true}, nil
}
// SetSiteStatusInput is the input for the SetSiteStatus activity.
type SetSiteStatusInput struct {
Domain string
Status string // active | readonly | archived
}
// SetSiteStatusOutput is the output for the SetSiteStatus activity.
type SetSiteStatusOutput struct {
Success bool
}
// SetSiteStatusActivity changes a site's lifecycle status on the FedWiki farm
// (PATCH) and projects the new status onto the local row. The active-usage
// adjustment is handled by the orchestrating workflow (saga), mirroring how
// create/delete reserve and compensate usage.
//
// This is the ONE place a member-driven status transition is written, so it is
// also where placement servability follows it (design D8): every member action
// that crosses the servable boundary — archive, restore, keep-active — reaches
// the farm through SetSiteStatusWorkflow and therefore through here. The
// direct SetSiteStatusByDomain writes elsewhere (reconcile.go's park/
// reactivate, swap.go) move only between active and readonly, both servable,
// and intentionally carry no servability write.
func (a *Activities) SetSiteStatusActivity(ctx context.Context, input SetSiteStatusInput) (*SetSiteStatusOutput, error) {
a.Logger.Info("setting FedWiki site status",
slog.String("domain", input.Domain),
slog.String("status", input.Status))
if _, err := a.Client.SetSiteStatus(ctx, input.Domain, input.Status); err != nil {
a.Logger.Error("failed to set site status via FarmManager API",
slog.String("domain", input.Domain),
slog.Any("error", err))
var apiErr *FarmManagerAPIError
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
return nil, temporal.NewNonRetryableApplicationError(
apiErr.UserMessage(),
"FarmManagerAPIError",
err,
)
}
return nil, fmt.Errorf("failed to set site status on FedWiki farm: %w", err)
}
// Project the observed status locally (the farm remains canonical; the next
// sync reconciles). archived_at is managed by the query.
site, err := a.SiteQ.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{
Domain: input.Domain,
Status: input.Status,
})
if err != nil {
a.Logger.Error("failed to project site status locally",
slog.String("domain", input.Domain),
slog.Any("error", err))
return nil, fmt.Errorf("site status changed on farm but failed to record locally: %w", err)
}
a.setPlacementServability(ctx, site.SiteID, input.Domain, input.Status)
return &SetSiteStatusOutput{Success: true}, nil
}
// setPlacementServability keeps the site's placement in step with its
// lifecycle: archived names stop being served, active and readonly ones serve.
// Best-effort — the status change already landed on the farm and locally, so a
// registry hiccup must not fail the transition; the next sync projection
// re-asserts servability from the observed status.
func (a *Activities) setPlacementServability(ctx context.Context, siteID, domain, status string) {
if a.Registry == nil || siteID == "" {
return
}
servable := IsServableStatus(status)
if _, err := a.Registry.SetServableByResource(ctx, fwmod.ModuleName, siteID, servable); err != nil {
a.Logger.Warn("failed to update placement servability",
slog.String("domain", domain),
slog.String("status", status),
slog.Bool("servable", servable),
slog.Any("error", err))
}
}
// IsServableStatus reports whether a site in this lifecycle state should have
// its name served: `active` and `readonly` do (a read-only wiki is still a
// wiki), `archived` does not.
func IsServableStatus(status string) bool { return status != "archived" }
// ListFedWikiSitesInput is the input for the ListFedWikiSites activity.
type ListFedWikiSitesInput struct{}
// ListFedWikiSitesOutput is the output for the ListFedWikiSites activity.
type ListFedWikiSitesOutput struct {
Sites []SiteInfo
}
// ListFedWikiSitesActivity lists all sites on the FedWiki farm.
func (a *Activities) ListFedWikiSitesActivity(ctx context.Context, input ListFedWikiSitesInput) (*ListFedWikiSitesOutput, error) {
a.Logger.Info("listing FedWiki sites")
sites, err := a.Client.ListSites(ctx)
if err != nil {
a.Logger.Error("failed to list sites via FarmManager API",
slog.Any("error", err))
return nil, fmt.Errorf("failed to list sites on FedWiki farm: %w", err)
}
a.Logger.Info("FedWiki sites listed successfully",
slog.Int("count", len(sites)))
return &ListFedWikiSitesOutput{Sites: sites}, nil
}
// CheckQuotaInput is the input for the CheckQuota activity.
type CheckQuotaInput struct {
WorkspaceID string
}
// CheckQuotaOutput is the output for the CheckQuota activity.
type CheckQuotaOutput struct {
CurrentCount int64
Quota int64
CanCreate bool
}
// CheckQuotaActivity checks if a workspace has quota available to create a new site
// by reading from the entitlements system.
func (a *Activities) CheckQuotaActivity(ctx context.Context, input CheckQuotaInput) (*CheckQuotaOutput, error) {
a.Logger.Info("checking workspace quota", slog.String("workspaceID", input.WorkspaceID))
// Converge the counter on the rows before comparing it to the limit: rows
// written outside the create workflow (import, farm sync) leave it
// understated, and an understated counter would let the create past a
// limit the member has already reached (design D2). Raise-only, so a
// concurrent create's reservation survives. Inside the activity, so the
// workflow's activity sequence — and every in-flight history — is unchanged.
if _, err := siteusage.ReconcileWorkspace(ctx, a.EntitlementsQ, a.SiteQ, input.WorkspaceID); err != nil {
return nil, fmt.Errorf("reconcile usage before quota check: %w", err)
}
assignment, err := a.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, input.WorkspaceID)
if err != nil {
a.Logger.Error("failed to get pool assignment",
slog.String("workspaceID", input.WorkspaceID),
slog.Any("error", err))
return nil, fmt.Errorf("failed to get pool assignment: %w", err)
}
ent, err := a.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
// No entitlement means no quota
a.Logger.Info("no sites entitlement found",
slog.String("workspaceID", input.WorkspaceID))
return &CheckQuotaOutput{
CurrentCount: 0,
Quota: 0,
CanCreate: false,
}, nil
}
usage, err := a.EntitlementsQ.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
return nil, fmt.Errorf("failed to get usage: %w", err)
}
canCreate := usage.CurrentUsage < ent.ResourceLimit
a.Logger.Info("quota check completed",
slog.String("workspaceID", input.WorkspaceID),
slog.Int64("currentUsage", usage.CurrentUsage),
slog.Int64("resourceLimit", ent.ResourceLimit),
slog.Bool("canCreate", canCreate))
return &CheckQuotaOutput{
CurrentCount: usage.CurrentUsage,
Quota: ent.ResourceLimit,
CanCreate: canCreate,
}, nil
}
// IncrementUsageInput is the input for the IncrementUsage activity.
type IncrementUsageInput struct {
WorkspaceID string
}
// IncrementUsageActivity atomically increments usage for the sites entitlement.
// Returns an error if the limit has been reached (zero rows updated).
func (a *Activities) IncrementUsageActivity(ctx context.Context, input IncrementUsageInput) error {
a.Logger.Info("incrementing site usage", slog.String("workspaceID", input.WorkspaceID))
entQ := entitlements.New(a.Database)
result, err := entQ.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: input.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
return fmt.Errorf("atomic increment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return temporal.NewNonRetryableApplicationError(
"Site quota exceeded",
"QuotaExceeded",
nil,
)
}
a.Logger.Info("site usage incremented", slog.String("workspaceID", input.WorkspaceID))
return nil
}
// DecrementUsageInput is the input for the DecrementUsage activity.
type DecrementUsageInput struct {
WorkspaceID string
}
// DecrementUsageActivity atomically decrements usage for the sites entitlement.
func (a *Activities) DecrementUsageActivity(ctx context.Context, input DecrementUsageInput) error {
a.Logger.Info("decrementing site usage", slog.String("workspaceID", input.WorkspaceID))
entQ := entitlements.New(a.Database)
_, err := entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: input.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
return fmt.Errorf("atomic decrement: %w", err)
}
a.Logger.Info("site usage decremented", slog.String("workspaceID", input.WorkspaceID))
return nil
}