-- ============================================================================ -- 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;