Files
member-console/internal/domains/queries/claims.sql
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

298 lines
14 KiB
SQL

-- name: CreateClaim :one
-- Callers pass normalized names (trimmed, lowercased, one trailing dot
-- stripped) and the matching label reversal; the registry owns both.
INSERT INTO domains.claims (
workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id,
status, token, expires_at, verified_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetClaimByID :one
SELECT * FROM domains.claims
WHERE claim_id = $1;
-- name: GetLiveClaimByRoot :one
-- A pending or active row holds the name (the partial unique index enforces
-- at most one); terminal rows do not block re-claiming.
SELECT * FROM domains.claims
WHERE root_fqdn = $1 AND status IN ('pending', 'active');
-- name: ListLiveClaimsByRoots :many
-- Ancestor walk: the caller passes the candidate name's suffixes (at most
-- 127) and reads the deepest match. Exact equality keeps this on the
-- live-root unique index.
SELECT * FROM domains.claims
WHERE root_fqdn = ANY(@roots::text[]) AND status IN ('pending', 'active')
ORDER BY length(root_fqdn) DESC;
-- name: ListLiveClaimsInSubtree :many
-- Descendant scan via the reversed-label prefix ("cafe.wiki" matches
-- "cafe.wiki.alice"). Excludes the subtree root itself — an exact-root
-- collision is the unique index's business.
SELECT * FROM domains.claims
WHERE status IN ('pending', 'active')
AND reversed_labels LIKE @reversed_root::text || '.%'
ORDER BY reversed_labels;
-- name: ListLiveClaimsByWorkspace :many
-- The member Domains surface: live claims with the placement count that
-- gates release, counted inline to avoid an N+1 per row.
SELECT c.*,
(SELECT COUNT(*) FROM domains.placements p WHERE p.claim_id = c.claim_id) AS placement_count
FROM domains.claims c
WHERE c.workspace_id = $1 AND c.status IN ('pending', 'active')
ORDER BY c.created_at DESC;
-- name: ListLiveClaimsForOperator :many
-- The operator moderation surface (design D7): every live claim in the
-- deployment with the holder attached, pending first and newest first inside
-- each group — a name under contest is a fresh pending claim, so that is the
-- order an operator scans.
--
-- This is the one query in the stream that reads core (the domains migration
-- grants core_reader to domains_writer for exactly this kind of read). It
-- projects columns rather than whole rows so no core model enters this
-- package's API — sqlc.yaml documents the rename entries a whole-row join
-- would otherwise need.
SELECT c.claim_id, c.root_fqdn, c.kind, c.status, c.created_at, c.evidence_at,
c.workspace_id, w.name AS workspace_name,
o.org_id, o.name AS org_name,
(SELECT COUNT(*) FROM domains.placements p WHERE p.claim_id = c.claim_id) AS placement_count
FROM domains.claims c
JOIN core.workspaces w ON w.workspace_id = c.workspace_id
JOIN core.organizations o ON o.org_id = w.org_id
WHERE c.status IN ('pending', 'active')
ORDER BY (c.status = 'pending') DESC, c.created_at DESC;
-- name: CountPendingClaimsByWorkspace :one
-- Soft cap on concurrent external verifications — each is a live 24-hour
-- polling workflow.
SELECT COUNT(*) FROM domains.claims
WHERE workspace_id = $1 AND status = 'pending';
-- name: ListExpiredPendingClaims :many
-- The expiry sweep's candidate read (claim-expiry-and-carve-guards D1). This
-- is the query idx_claims_pending_expires_at was created for: the partial
-- index over (expires_at) WHERE status = 'pending' answers it directly, and
-- nothing else in the registry asks a question shaped like it.
--
-- @deadline is now minus the sweep's grace period, not now. A healthy
-- verification workflow marks its own claim expired within one poll of the
-- deadline and carries the final probe's evidence verdict with it; the sweep
-- carries none, so it must not win that race. Trailing the deadline by more
-- than the poll cap makes the sweep the backstop it is meant to be, for claims
-- whose workflow is gone.
--
-- Deliberately unlimited: every row a pass finds it makes terminal, so the
-- candidate set drains rather than accumulating.
SELECT claim_id, workspace_id, root_fqdn, expires_at
FROM domains.claims
WHERE status = 'pending' AND expires_at < @deadline::timestamptz
ORDER BY expires_at;
-- name: MarkClaimActive :execrows
-- Verification success. Guarded on pending so a late workflow write never
-- reanimates a canceled or expired claim.
--
-- Proving control wipes this workspace's abandonment slate for the verified
-- name AND ITS OWN SUBTREE (design D3) — deliberately NOT for the wider scope
-- the ledger counts under. The reach is read from the row being activated, so
-- the clear and the activation commit or roll back together.
--
-- The two widths differ on purpose. Counting by scope is what makes cycling
-- sibling names cost something; clearing by scope would hand that straight
-- back to anyone holding ONE name under a shared root. A workspace that owns
-- attacker.co.uk could burn the co.uk-scoped budget aimed at bakery.co.uk,
-- verify its own name, watch the ledger null out, and cycle forever. Control
-- of one name proves nothing about its siblings, so it clears nothing about
-- them.
--
-- The two UPDATEs touch disjoint rows: `cleared` only ever matches terminal
-- rows carrying abandoned_at, and the claim being activated is pending, so its
-- abandoned_at is NULL.
WITH verified AS (
SELECT workspace_id, reversed_labels
FROM domains.claims
WHERE claim_id = $1 AND status = 'pending'
), cleared AS (
UPDATE domains.claims c
SET abandoned_at = NULL, updated_at = NOW()
FROM verified v
WHERE c.workspace_id = v.workspace_id
AND c.abandoned_at IS NOT NULL
AND (c.reversed_labels = v.reversed_labels OR c.reversed_labels LIKE v.reversed_labels || '.%')
)
UPDATE domains.claims activated
SET status = 'active', verified_at = NOW(), updated_at = NOW()
WHERE activated.claim_id = $1 AND activated.status = 'pending';
-- name: MarkClaimExpired :execrows
-- The window elapsed with no TXT match. Records an abandonment unless the
-- claim earned the evidence latch (design D3): a member who published our
-- challenge value demonstrably worked on their DNS and is never charged.
--
-- @evidence carries what the FINAL probe saw, because the latch's usual
-- recovery ("the next probe re-stamps a lost write") has no next probe here:
-- this statement runs immediately after that probe, and a RecordClaimProbe
-- write that failed would otherwise be paid for by the member. It latches
-- evidence_at on the same COALESCE terms as a probe write, so the row an
-- operator reads afterwards says what was actually observed.
UPDATE domains.claims
SET status = 'expired', updated_at = NOW(),
evidence_at = COALESCE(evidence_at, CASE WHEN @evidence::boolean THEN NOW() END),
abandoned_at = CASE WHEN evidence_at IS NULL AND NOT @evidence::boolean THEN NOW() END
WHERE claim_id = $1 AND status = 'pending';
-- name: MarkClaimCanceled :execrows
-- Member-initiated. Guarded on pending: a cancel that races the active
-- transition loses and updates nothing (design D4).
--
-- @evidence carries what a fresh cancel-time probe saw (design D5), on the
-- SAME final-probe-decides contract MarkClaimExpired documents above: a
-- member can cancel before any background probe ever ran, so the stored
-- latch alone has nothing to read in that case, and the caller must gather
-- its own evidence immediately before this write. It latches evidence_at on
-- the same COALESCE terms as a probe write, and abandoned_at is stamped only
-- when neither the stored latch nor this final probe found evidence — the
-- evidence-latch parity this comment used to claim without carrying the
-- parameter to back it.
UPDATE domains.claims
SET status = 'canceled', updated_at = NOW(),
evidence_at = COALESCE(evidence_at, CASE WHEN @evidence::boolean THEN NOW() END),
abandoned_at = CASE WHEN evidence_at IS NULL AND NOT @evidence::boolean THEN NOW() END
WHERE claim_id = $1 AND status = 'pending';
-- name: MarkClaimCanceledSystem :execrows
-- Console-initiated rollback: the claim committed but its verification
-- workflow could not be started, so the name is freed again. Deliberately
-- never stamps abandoned_at — a Temporal outage is not the member's fault and
-- must never debit the ledger (design D4).
--
-- It stamps system_canceled_at instead, which is the same fact stated
-- positively: the ledger is one budget, and ListRecentClaimInitiations is the
-- other. That one counts every external claim this workspace created, so
-- without a mark on the row an outage during ten attempts would still lock the
-- workspace out for a day — charging the member for the console's failure by a
-- different door.
UPDATE domains.claims
SET status = 'canceled', system_canceled_at = NOW(), updated_at = NOW()
WHERE claim_id = $1 AND status = 'pending';
-- name: MarkClaimCanceledForce :execrows
-- Operator moderation (design D7): the terminal write for a force-released
-- PENDING claim. Stamps the ledger unconditionally, where the member-facing
-- cancel exempts a claim carrying evidence — an operator taking a name back is
-- itself the finding that the claim was unwanted, and a squatter who published
-- a decoy challenge record must not buy immunity from moderation with it.
-- Pending-guarded like every other terminal write: a force-release that loses
-- its race to verification updates nothing.
UPDATE domains.claims
SET status = 'canceled', updated_at = NOW(), abandoned_at = NOW()
WHERE claim_id = $1 AND status = 'pending';
-- name: MarkClaimReleased :execrows
-- Terminal state for a claim freed after being active (auto-release of an
-- emptied carved claim, or an explicit member release). Guarded on active
-- so it cannot short-circuit a pending verification.
UPDATE domains.claims
SET status = 'released', updated_at = NOW()
WHERE claim_id = $1 AND status = 'active';
-- name: RecordClaimProbe :execrows
-- Latest per-record probe result, written by the verification workflow.
-- Guarded on pending so a late probe never touches a terminal row.
--
-- @evidence latches the first probe that observed OUR challenge value prefix
-- at the challenge name (design D2); the workflow computes it, because only
-- the raw observations distinguish our record from anything else published
-- there. COALESCE makes the latch sticky and the write idempotent, so a lost
-- write is recovered by the next probe.
UPDATE domains.claims
SET txt_state = $2, txt_observed = $3,
connect_state = $4, connect_observed = $5,
evidence_at = COALESCE(evidence_at, CASE WHEN @evidence::boolean THEN NOW() END),
last_checked_at = NOW(), updated_at = NOW()
WHERE claim_id = $1 AND status = 'pending';
-- name: ListAbandonmentsInScope :many
-- The abandonment ledger read (design D3): one workspace's abandonments
-- inside a candidate's scope, NEWEST first, capped at the budget.
--
-- The scope predicate is the scope root AND everything beneath it, so cycling
-- sibling names (a.example.org, b.example.org, …) counts as cycling one name;
-- an ancestor-only or per-name predicate would not close that. Callers pass
-- the budget as the limit: exactly @budget rows back means the budget is
-- exhausted.
--
-- Newest first, so the LAST row is the entry whose ageing out reopens the
-- scope. Oldest-first would name the globally oldest entry, which is the wrong
-- answer whenever a workspace holds MORE entries than the budget — the pending
-- cap alone lets it bank five against a budget of three, and the member would
-- be told to come back at a time they are still refused. The scope reopens
-- when fewer than @budget entries remain in the window, which is when the
-- @budget-th newest ages out.
SELECT abandoned_at FROM domains.claims
WHERE workspace_id = @workspace_id
AND abandoned_at IS NOT NULL
AND abandoned_at >= @since::timestamptz
AND (reversed_labels = @scope_rev::text OR reversed_labels LIKE @scope_rev::text || '.%')
ORDER BY abandoned_at DESC
LIMIT @budget::bigint;
-- name: ListTerminalClaimsForOperator :many
-- The operator claims ledger section (ux-ia-naming task 9.2): every claim
-- that has left a live state, deployment-wide, newest first. Today only live
-- claims render on the moderation surface; this read makes expired,
-- canceled, and released claims reachable too, in a list segregated from the
-- live one.
--
-- The three ledger columns ride along unconditionally (not just for external
-- claims) so the handler can decide presentation; blame is read from these
-- columns, never inferred from status (invariant 9 -- status alone says
-- nothing about fault). Capped at 200: this is a page display read, not a
-- business-rule cap like the budget queries above, and terminal rows only
-- accumulate, so an unbounded read would eventually make the section
-- unusable rather than just long.
SELECT c.claim_id, c.root_fqdn, c.kind, c.status, c.created_at,
c.evidence_at, c.abandoned_at, c.system_canceled_at,
c.workspace_id, w.name AS workspace_name,
o.org_id, o.name AS org_name
FROM domains.claims c
JOIN core.workspaces w ON w.workspace_id = c.workspace_id
JOIN core.organizations o ON o.org_id = w.org_id
WHERE c.status IN ('expired', 'canceled', 'released')
ORDER BY c.created_at DESC
LIMIT 200;
-- name: ListRecentClaimInitiations :many
-- The breadth budget read (design D5): external claims this workspace started
-- in the rolling window, newest first, capped at the budget. Counted from
-- created_at, so a claim still pending counts exactly like one already given
-- up — the cap bounds how fast a workspace may spray new scopes at all.
--
-- Newest first for the same reason as ListAbandonmentsInScope: the LAST row is
-- the one whose ageing out reopens the budget, and a workspace can hold more
-- rows than the budget whenever an operator lowers it mid-window.
--
-- Two kinds of row are NOT initiations and are excluded:
--
-- - `expires_at IS NULL` is a grandfathered adoption. Boot reconciliation
-- writes an active external claim per pre-registry custom domain (design
-- D8) with no token and no window, so a workspace with ten adopted domains
-- would otherwise be refused every new claim for a day after each restart.
-- Every claim a member initiates carries the window it was created with;
-- no grandfathered claim carries one.
-- - `system_canceled_at IS NOT NULL` is a claim the console rolled back
-- because it could not start the verification workflow. D4 is that a
-- console failure is never charged to the member, and the initiation
-- budget is a charge.
SELECT created_at FROM domains.claims
WHERE workspace_id = @workspace_id
AND kind = 'external'
AND created_at >= @since::timestamptz
AND expires_at IS NOT NULL
AND system_canceled_at IS NULL
ORDER BY created_at DESC
LIMIT @budget::bigint;