d3ro-voice/server/supabase/migrations/20260929020000_unify_llm_quota_inflight.sql
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

830 lines
30 KiB
PL/PgSQL

-- ============================================================================
-- 20260929020000_unify_llm_quota_inflight.sql
--
-- One LLM quota ledger and one lock for every path that spends LLM allowance.
--
-- Bug
-- Meeting-document generation held its in-flight unit in a different ledger
-- and under a different lock than llm-proxy / consume_quota:
-- * claim_meeting_document_generation_v1 (20260928000037) locked
-- hashtextextended(user:feature, 0) and counted daily_usage PLUS
-- meeting_document_generation_requests rows in status 'processing';
-- it wrote nothing to daily_usage;
-- * reserve_llm_quota / finalize_llm_quota (20260928020800) and
-- consume_quota (20260928000131) locked hashtextextended(user:feature,
-- 20260928) and counted daily_usage only.
-- So an in-flight document unit was invisible to llm-proxy. With one Opus
-- unit left, a document claim passed (49 < 50), a Talk/command request then
-- reserved the same unit through llm-proxy (daily_usage 49 -> 50), and
-- commit_meeting_document_generation_v1 re-checked the allowance, saw
-- 50 >= 50 and raised generation_quota_exceeded: the paid Opus generation
-- was thrown away (or, with overage credits, a credit the claim promised
-- was covered by the base allowance was spent silently). The two paths also
-- never serialised against each other because the lock keys differed.
--
-- Fix (single source of truth for LLM quota state)
-- 1. public.daily_usage_lock_v1(user, feature) is the only place that knows
-- the per-user/per-feature advisory-lock key for the daily_usage ledger.
-- reserve_llm_quota, finalize_llm_quota, consume_quota and the three
-- meeting-document RPCs all take the lock through it.
-- 2. A meeting-document claim now reserves its unit with reserve_llm_quota
-- (a fresh server-generated reservation id, stored on the request row in
-- llm_reservation_id). The unit is in daily_usage from the moment of the
-- claim, so llm-proxy and consume_quota see it.
-- commit -> finalize_llm_quota(id, true) (the unit stays spent;
-- consumedFrom comes from the reservation)
-- fail -> finalize_llm_quota(id, false) (daily_usage -1, overage
-- credit refunded when the unit came from overage)
-- The separate 'processing'-row in-flight count and commit's allowance
-- re-check / daily_usage insert are gone, so usage is counted once.
-- 3. Crashed workers: the reservation lease (10 minutes) is reclaimed by the
-- next reserve for that user/feature, exactly like llm-proxy. That
-- replaces the old "processing rows older than 10 minutes stop counting"
-- rule.
--
-- Single-path behaviour is unchanged: "allowed" is still
-- usage + in-flight < base limit + overage, because daily_usage now includes
-- in-flight units. What moves: document usage is recorded at claim instead of
-- commit, and an overage credit is taken at claim and refunded on failure
-- (the llm-proxy model).
--
-- Compatibility
-- * RPC signatures and return shapes are unchanged.
-- * Request rows that were already 'processing' when this migration ran have
-- llm_reservation_id NULL. commit charges them through the same ledger at
-- commit time (the previous behaviour), and fail has nothing to release.
-- The same path covers a reservation whose lease expired and was
-- reclaimed before commit arrived.
-- * The reservation id is generated server-side (gen_random_uuid), never the
-- client's idempotency key: idempotency keys are unique only per user,
-- llm_quota_reservations.id is a global primary key.
--
-- Only service_role may call the quota functions. Local verification:
-- tests/meeting-document-generation-quota.integration.sql.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. The single lock for the daily_usage quota ledger.
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.daily_usage_lock_v1(
p_user_id uuid,
p_feature text
) RETURNS void
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
BEGIN
IF p_user_id IS NULL OR p_feature IS NULL THEN
RAISE EXCEPTION 'invalid_daily_usage_lock' USING ERRCODE = '22023';
END IF;
-- Seed 20260928 is the key consume_quota and reserve_llm_quota already used,
-- so existing sessions of those functions keep serialising with new ones.
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || p_feature, 20260928));
END;
$$;
REVOKE ALL ON FUNCTION public.daily_usage_lock_v1(uuid, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.daily_usage_lock_v1(uuid, text) TO service_role;
COMMENT ON FUNCTION public.daily_usage_lock_v1(uuid, text) IS
'Transaction advisory lock guarding the daily_usage quota ledger for one user/feature. Every quota path (consume_quota, reserve/finalize_llm_quota, meeting-document claim/commit/fail) must take it through this function.';
-- ----------------------------------------------------------------------------
-- 2. Request rows point at the reservation that holds their unit.
-- ----------------------------------------------------------------------------
ALTER TABLE public.meeting_document_generation_requests
ADD COLUMN IF NOT EXISTS llm_reservation_id uuid
REFERENCES public.llm_quota_reservations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS meeting_document_generation_llm_reservation_idx
ON public.meeting_document_generation_requests(llm_reservation_id)
WHERE llm_reservation_id IS NOT NULL;
-- The 'processing'-row in-flight count is replaced by the reservation ledger.
DROP INDEX IF EXISTS public.meeting_document_generation_in_flight_idx;
-- ----------------------------------------------------------------------------
-- 3. reserve_llm_quota / finalize_llm_quota / consume_quota: same bodies as
-- 20260928020800 and 20260928000131, lock taken through the helper.
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.reserve_llm_quota(
p_user_id uuid,
p_reservation_id uuid,
p_feature text,
p_base_limit integer,
p_period text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
existing public.llm_quota_reservations%ROWTYPE;
expired public.llm_quota_reservations%ROWTYPE;
subscription_tier text := 'free';
overage integer := 0;
new_overage integer;
current_count integer := 0;
consumed_from text;
BEGIN
IF p_user_id IS NULL
OR p_reservation_id IS NULL
OR p_feature IS NULL
OR p_feature NOT IN ('llm_haiku', 'llm_sonnet', 'llm_opus')
OR p_base_limit IS NULL
OR p_base_limit < -1
OR p_period IS NULL
OR p_period NOT IN ('daily', 'weekly') THEN
RAISE EXCEPTION 'invalid_llm_quota_reservation' USING ERRCODE = '22023';
END IF;
PERFORM public.daily_usage_lock_v1(p_user_id, p_feature);
SELECT * INTO existing
FROM public.llm_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF FOUND THEN
IF existing.user_id <> p_user_id OR existing.feature <> p_feature THEN
RAISE EXCEPTION 'llm_quota_reservation_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN jsonb_build_object(
'allowed', existing.status IN ('reserved', 'completed'),
'reservation_id', existing.id,
'status', existing.status,
'current', existing.current_count,
'limit', existing.quota_limit,
'period', existing.quota_period,
'tier', existing.tier,
'overage_credits', existing.overage_after,
'consumed_from', existing.consumed_from
);
END IF;
-- Reclaim crashed requests before calculating the next allowance.
FOR expired IN
SELECT *
FROM public.llm_quota_reservations
WHERE user_id = p_user_id
AND feature = p_feature
AND status = 'reserved'
AND lease_expires_at <= now()
FOR UPDATE
LOOP
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = expired.user_id
AND date = expired.usage_date
AND feature = expired.feature;
IF expired.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = expired.user_id;
END IF;
UPDATE public.llm_quota_reservations
SET status = 'released', finalized_at = now(), release_reason = 'lease_expired'
WHERE id = expired.id;
END LOOP;
SELECT coalesce(tier, 'free'), coalesce(overage_credits, 0)
INTO subscription_tier, overage
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF NOT FOUND THEN
subscription_tier := 'free';
overage := 0;
END IF;
-- Not available: never spend credits on a model the tier does not include.
IF p_base_limit = 0 THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', 0,
'limit', 0,
'period', p_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', 'none'
);
END IF;
-- daily_usage already includes units held by in-flight reservations
-- (llm-proxy requests and meeting-document claims alike).
SELECT coalesce(sum(count), 0)::integer INTO current_count
FROM public.daily_usage
WHERE user_id = p_user_id
AND feature = p_feature
AND date >= CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END
AND date <= CURRENT_DATE;
IF p_base_limit = -1 THEN
consumed_from := 'unlimited';
ELSIF current_count < p_base_limit THEN
consumed_from := 'base';
ELSE
UPDATE public.subscriptions
SET overage_credits = overage_credits - 1,
updated_at = now()
WHERE user_id = p_user_id
AND overage_credits > 0
RETURNING overage_credits INTO new_overage;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', current_count,
'limit', p_base_limit,
'period', p_period,
'tier', subscription_tier,
'overage_credits', 0,
'consumed_from', 'none'
);
END IF;
overage := new_overage;
consumed_from := 'overage';
END IF;
INSERT INTO public.daily_usage(user_id, date, feature, count)
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
ON CONFLICT (user_id, date, feature)
DO UPDATE SET count = public.daily_usage.count + 1;
current_count := current_count + 1;
INSERT INTO public.llm_quota_reservations(
id, user_id, feature, consumed_from, tier, quota_period, quota_limit,
current_count, overage_after, lease_expires_at
) VALUES (
p_reservation_id, p_user_id, p_feature, consumed_from, subscription_tier, p_period, p_base_limit,
current_count, overage, now() + interval '10 minutes'
);
RETURN jsonb_build_object(
'allowed', true,
'reservation_id', p_reservation_id,
'status', 'reserved',
'current', current_count,
'limit', p_base_limit,
'period', p_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', consumed_from
);
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_llm_quota(
p_reservation_id uuid,
p_succeeded boolean
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
reservation public.llm_quota_reservations%ROWTYPE;
final_status text;
BEGIN
IF p_reservation_id IS NULL OR p_succeeded IS NULL THEN
RAISE EXCEPTION 'invalid_llm_quota_finalize' USING ERRCODE = '22023';
END IF;
SELECT * INTO reservation
FROM public.llm_quota_reservations
WHERE id = p_reservation_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'llm_quota_reservation_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM public.daily_usage_lock_v1(reservation.user_id, reservation.feature);
SELECT * INTO reservation
FROM public.llm_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF reservation.status <> 'reserved' THEN
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', reservation.status);
END IF;
IF p_succeeded THEN
final_status := 'completed';
ELSE
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = reservation.user_id
AND date = reservation.usage_date
AND feature = reservation.feature;
IF reservation.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = reservation.user_id;
END IF;
final_status := 'released';
END IF;
UPDATE public.llm_quota_reservations
SET status = final_status,
finalized_at = now(),
release_reason = CASE WHEN p_succeeded THEN NULL ELSE 'provider_failed' END
WHERE id = reservation.id;
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', final_status);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_llm_quota(uuid, boolean) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_llm_quota(uuid, uuid, text, integer, text) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_llm_quota(uuid, boolean) TO service_role;
CREATE OR REPLACE FUNCTION public.consume_quota(
p_user_id uuid,
p_feature text,
p_base_limit integer,
p_period text DEFAULT 'daily'
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_window_start date;
v_current integer := 0;
v_overage integer := 0;
v_new_overage integer;
v_consumed_from text;
BEGIN
IF p_user_id IS NULL
OR p_feature IS NULL
OR p_base_limit IS NULL
OR p_base_limit < -1
OR p_period IS NULL
OR p_period NOT IN ('daily', 'weekly') THEN
RAISE EXCEPTION 'invalid_quota_consumption' USING ERRCODE = '22023';
END IF;
-- Serialise read-then-increment for this user/feature.
PERFORM public.daily_usage_lock_v1(p_user_id, p_feature);
SELECT coalesce(overage_credits, 0) INTO v_overage
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
v_overage := coalesce(v_overage, 0);
-- Not available: never spend credits on a feature the tier does not include.
IF p_base_limit = 0 THEN
RETURN jsonb_build_object(
'allowed', false,
'current', 0,
'limit', 0,
'overage_credits', v_overage,
'consumed_from', 'none'
);
END IF;
v_window_start := CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END;
SELECT coalesce(sum(count), 0)::integer INTO v_current
FROM public.daily_usage
WHERE user_id = p_user_id
AND feature = p_feature
AND date >= v_window_start
AND date <= CURRENT_DATE;
IF p_base_limit = -1 THEN
v_consumed_from := 'unlimited';
ELSIF v_current < p_base_limit THEN
v_consumed_from := 'base';
ELSE
UPDATE public.subscriptions
SET overage_credits = overage_credits - 1,
updated_at = now()
WHERE user_id = p_user_id
AND overage_credits > 0
RETURNING overage_credits INTO v_new_overage;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'allowed', false,
'current', v_current,
'limit', p_base_limit,
'overage_credits', 0,
'consumed_from', 'none'
);
END IF;
v_overage := v_new_overage;
v_consumed_from := 'overage';
END IF;
INSERT INTO public.daily_usage (user_id, date, feature, count)
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
ON CONFLICT (user_id, date, feature) DO UPDATE
SET count = public.daily_usage.count + 1;
RETURN jsonb_build_object(
'allowed', true,
'current', v_current + 1,
'limit', p_base_limit,
'overage_credits', v_overage,
'consumed_from', v_consumed_from
);
END;
$$;
REVOKE ALL ON FUNCTION public.consume_quota(uuid, text, integer, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.consume_quota(uuid, text, integer, text) TO service_role;
-- ----------------------------------------------------------------------------
-- 4. Meeting-document claim: reserve the unit in the shared ledger.
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.claim_meeting_document_generation_v1(
p_actor_id uuid,
p_idempotency_key uuid,
p_meeting_id uuid,
p_template_id uuid,
p_title text,
p_model text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
meeting_row public.meetings;
template_row public.user_templates;
transcript_value text;
transcript_digest text;
request_digest text;
request_row public.meeting_document_generation_requests;
inserted boolean := false;
tier_value text := 'free';
quota_feature_value text;
quota_limit_value integer;
quota_period_value text;
reservation_id_value uuid;
reservation jsonb;
is_replay boolean := false;
safe_title text := trim(p_title);
BEGIN
IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_meeting_id IS NULL OR p_template_id IS NULL THEN
RAISE EXCEPTION 'generation_identifiers_required' USING ERRCODE = '22023';
END IF;
IF char_length(safe_title) NOT BETWEEN 1 AND 160 THEN
RAISE EXCEPTION 'invalid_document_title' USING ERRCODE = '22023';
END IF;
IF p_model NOT IN ('claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6') THEN
RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023';
END IF;
SELECT * INTO meeting_row FROM public.meetings WHERE id = p_meeting_id;
IF meeting_row.id IS NULL THEN
RAISE EXCEPTION 'meeting_not_found' USING ERRCODE = 'P0002';
END IF;
IF meeting_row.user_id <> p_actor_id AND NOT (
meeting_row.team_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM public.teams
WHERE id = meeting_row.team_id AND owner_id = p_actor_id
)
OR EXISTS (
SELECT 1 FROM public.team_members
WHERE team_id = meeting_row.team_id
AND user_id = p_actor_id
AND role IN ('owner', 'admin')
)
)
) THEN
RAISE EXCEPTION 'meeting_generation_forbidden' USING ERRCODE = '42501';
END IF;
SELECT * INTO template_row
FROM public.user_templates
WHERE id = p_template_id
AND user_id = p_actor_id
AND template_kind = 'meeting_document';
IF template_row.id IS NULL THEN
RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002';
END IF;
SELECT nullif(string_agg(
CASE WHEN nullif(trim(transcript.speaker), '') IS NULL
THEN transcript.text
ELSE trim(transcript.speaker) || ': ' || transcript.text
END,
E'\n' ORDER BY transcript.segment_index
), '')
INTO transcript_value
FROM public.transcripts AS transcript
WHERE transcript.meeting_id = meeting_row.id;
transcript_value := coalesce(
transcript_value,
nullif(trim(meeting_row.edited_transcript), ''),
nullif(trim(meeting_row.raw_transcript), '')
);
IF transcript_value IS NULL THEN
RAISE EXCEPTION 'meeting_transcript_required' USING ERRCODE = '22023';
END IF;
IF char_length(transcript_value) > 48000 THEN
RAISE EXCEPTION 'meeting_transcript_too_large' USING ERRCODE = '22023';
END IF;
SELECT coalesce(subscription.tier, 'free')
INTO tier_value
FROM public.subscriptions AS subscription
WHERE subscription.user_id = p_actor_id;
tier_value := coalesce(tier_value, 'free');
IF tier_value = 'free' AND p_model <> 'claude-haiku-4-5-20251001' THEN
RAISE EXCEPTION 'generation_model_not_allowed' USING ERRCODE = '42501';
END IF;
quota_feature_value := CASE
WHEN p_model LIKE '%sonnet%' THEN 'llm_sonnet'
WHEN p_model LIKE '%opus%' THEN 'llm_opus'
ELSE 'llm_haiku'
END;
quota_limit_value := CASE
WHEN tier_value = 'free' AND quota_feature_value = 'llm_haiku' THEN 250
WHEN tier_value = 'free' THEN 0
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_haiku' THEN 1500
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_sonnet' THEN 300
WHEN tier_value = 'pro' AND quota_feature_value = 'llm_opus' THEN 50
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_haiku' THEN -1
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_sonnet' THEN 1500
WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_opus' THEN 300
WHEN tier_value = 'team' AND quota_feature_value = 'llm_haiku' THEN -1
WHEN tier_value = 'team' AND quota_feature_value = 'llm_sonnet' THEN 3000
WHEN tier_value = 'team' AND quota_feature_value = 'llm_opus' THEN 600
WHEN tier_value = 'enterprise' THEN -1
ELSE 0
END;
quota_period_value := CASE WHEN tier_value = 'free' THEN 'weekly' ELSE 'daily' END;
IF quota_limit_value = 0 THEN
RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001';
END IF;
-- The shared ledger lock: claims, commits, fails, llm-proxy reservations and
-- consume_quota for one user/feature all serialise here.
PERFORM public.daily_usage_lock_v1(p_actor_id, quota_feature_value);
SELECT EXISTS (
SELECT 1 FROM public.meeting_document_generation_requests
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key
) INTO is_replay;
-- A replay never starts provider work, so it takes no unit.
IF NOT is_replay THEN
reservation_id_value := gen_random_uuid();
reservation := public.reserve_llm_quota(
p_actor_id, reservation_id_value, quota_feature_value, quota_limit_value, quota_period_value
);
IF (reservation->>'allowed')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001';
END IF;
END IF;
transcript_digest := encode(extensions.digest(transcript_value, 'sha256'), 'hex');
request_digest := encode(extensions.digest(
jsonb_build_object(
'meeting_id', meeting_row.id,
'template_id', template_row.id,
'template_revision', template_row.revision,
'transcript_hash', transcript_digest,
'title', safe_title,
'model', p_model
)::text,
'sha256'
), 'hex');
INSERT INTO public.meeting_document_generation_requests(
user_id, idempotency_key, meeting_id, template_id, request_hash,
document_title, model, quota_feature, quota_limit, quota_period,
template_revision, template_type, transcript_hash, status, llm_reservation_id
)
VALUES (
p_actor_id, p_idempotency_key, meeting_row.id, template_row.id, request_digest,
safe_title, p_model, quota_feature_value, quota_limit_value, quota_period_value,
template_row.revision, template_row.template_type, transcript_digest, 'processing',
reservation_id_value
)
ON CONFLICT DO NOTHING
RETURNING true INTO inserted;
SELECT * INTO request_row
FROM public.meeting_document_generation_requests
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key;
IF NOT coalesce(inserted, false) THEN
IF request_row.request_hash <> request_digest THEN
RAISE EXCEPTION 'generation_idempotency_conflict' USING ERRCODE = '22023';
END IF;
-- A concurrent claim for the same key won the insert; give our unit back.
IF reservation_id_value IS NOT NULL THEN
PERFORM public.finalize_llm_quota(reservation_id_value, false);
END IF;
END IF;
RETURN jsonb_build_object(
'claimed', coalesce(inserted, false),
'status', request_row.status,
'documentId', request_row.document_id,
'meetingTitle', coalesce(meeting_row.title, 'Meeting'),
'documentTitle', request_row.document_title,
'templateType', request_row.template_type,
'systemPrompt', CASE WHEN coalesce(inserted, false) THEN template_row.system_prompt ELSE NULL END,
'transcript', CASE WHEN coalesce(inserted, false) THEN transcript_value ELSE NULL END,
'model', request_row.model
);
END;
$$;
REVOKE ALL ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text)
TO service_role;
-- ----------------------------------------------------------------------------
-- 5. Meeting-document fail: release the reserved unit.
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fail_meeting_document_generation_v1(
p_actor_id uuid,
p_idempotency_key uuid,
p_error_code text
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
changed integer;
held_reservation uuid;
BEGIN
IF p_error_code NOT IN (
'provider_unavailable', 'provider_timeout', 'provider_request_failed',
'provider_invalid_response', 'quota_exceeded', 'commit_failed'
) THEN
RAISE EXCEPTION 'invalid_generation_error_code' USING ERRCODE = '22023';
END IF;
UPDATE public.meeting_document_generation_requests
SET status = 'failed', error_code = p_error_code, completed_at = now()
WHERE user_id = p_actor_id
AND idempotency_key = p_idempotency_key
AND status = 'processing'
RETURNING llm_reservation_id INTO held_reservation;
GET DIAGNOSTICS changed = ROW_COUNT;
-- Rows claimed before 20260929020000 hold no reservation: nothing to release.
-- finalize is a no-op for a reservation whose lease was already reclaimed.
IF changed > 0 AND held_reservation IS NOT NULL THEN
PERFORM public.finalize_llm_quota(held_reservation, false);
END IF;
RETURN changed > 0;
END;
$$;
REVOKE ALL ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text)
TO service_role;
-- ----------------------------------------------------------------------------
-- 6. Meeting-document commit: settle the reserved unit, never re-evaluate it.
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.commit_meeting_document_generation_v1(
p_actor_id uuid,
p_idempotency_key uuid,
p_content text,
p_latency_ms integer,
p_input_tokens integer DEFAULT NULL,
p_output_tokens integer DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
request_row public.meeting_document_generation_requests;
document_row public.meeting_documents;
settled jsonb;
reservation jsonb;
charged_reservation uuid;
consumed_from text;
BEGIN
IF char_length(trim(p_content)) NOT BETWEEN 1 AND 100000
OR p_latency_ms NOT BETWEEN 0 AND 600000
OR (p_input_tokens IS NOT NULL AND p_input_tokens < 0)
OR (p_output_tokens IS NOT NULL AND p_output_tokens < 0) THEN
RAISE EXCEPTION 'invalid_generation_result' USING ERRCODE = '22023';
END IF;
SELECT * INTO request_row
FROM public.meeting_document_generation_requests
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key
FOR UPDATE;
IF request_row.user_id IS NULL THEN
RAISE EXCEPTION 'generation_request_not_found' USING ERRCODE = 'P0002';
END IF;
IF request_row.status = 'succeeded' THEN
SELECT * INTO document_row FROM public.meeting_documents WHERE id = request_row.document_id;
RETURN jsonb_build_object('idempotent', true, 'document', to_jsonb(document_row));
END IF;
IF request_row.status <> 'processing' THEN
RAISE EXCEPTION 'generation_request_not_committable' USING ERRCODE = '55000';
END IF;
PERFORM public.daily_usage_lock_v1(p_actor_id, request_row.quota_feature);
charged_reservation := request_row.llm_reservation_id;
IF charged_reservation IS NOT NULL THEN
-- The claim already holds the unit: spend it (no allowance re-check).
settled := public.finalize_llm_quota(charged_reservation, true);
END IF;
IF settled->>'status' = 'completed' THEN
SELECT reservation_row.consumed_from
INTO consumed_from
FROM public.llm_quota_reservations AS reservation_row
WHERE reservation_row.id = charged_reservation;
ELSE
-- No unit is held: a row claimed before 20260929020000, or a reservation
-- whose lease expired and was reclaimed before commit arrived. Charge one
-- unit now through the same ledger (the previous commit-time rule).
charged_reservation := gen_random_uuid();
reservation := public.reserve_llm_quota(
p_actor_id, charged_reservation, request_row.quota_feature,
request_row.quota_limit, request_row.quota_period
);
IF (reservation->>'allowed')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001';
END IF;
PERFORM public.finalize_llm_quota(charged_reservation, true);
consumed_from := reservation->>'consumed_from';
END IF;
INSERT INTO public.meeting_documents(
meeting_id, user_id, template_type, title, content, prompt_used,
llm_model, llm_latency_ms, template_id, generation_idempotency_key
)
VALUES (
request_row.meeting_id,
p_actor_id,
request_row.template_type,
request_row.document_title,
trim(p_content),
'template:' || request_row.template_id::text || '@' || request_row.template_revision::text,
request_row.model,
p_latency_ms,
request_row.template_id,
p_idempotency_key
)
RETURNING * INTO document_row;
INSERT INTO public.meeting_document_generation_audit(
user_id, meeting_id, template_id, document_id, idempotency_key,
model, template_revision, transcript_hash, input_tokens, output_tokens, latency_ms
)
VALUES (
p_actor_id, request_row.meeting_id, request_row.template_id, document_row.id,
p_idempotency_key, request_row.model, request_row.template_revision,
request_row.transcript_hash, p_input_tokens, p_output_tokens, p_latency_ms
);
UPDATE public.meeting_document_generation_requests
SET status = 'succeeded',
document_id = document_row.id,
llm_reservation_id = charged_reservation,
error_code = NULL,
completed_at = now()
WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key;
RETURN jsonb_build_object(
'idempotent', false,
'consumedFrom', consumed_from,
'document', to_jsonb(document_row)
);
END;
$$;
REVOKE ALL ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer)
TO service_role;