feat(core,api): record burn as rate segments, and refuse the next run when spent

The counters this fills are the ones the windows already knew how to read.
What was missing was anything that put a number in them.

Burn is recorded as segments: one stretch of one run at one unchanging rate,
opened when the rate becomes true and closed when it stops being. Not a row per
session, because a session's rate does not survive its own lifetime — a second
run changes what the account spends per second while the first is still going,
and a rate that applied from that moment must not be backdated over the time
before it. Not a row per event either, because burn accrues against an envelope
that is held rather than per thing consumed.

Closing a segment is what moves burn into the counters, so a long run lands
incrementally instead of all at the end. Burn that only arrives when a session
stops is burn that cannot refuse the next one, and a bar that does not move
while something is running is a bar nobody believes.

The counters are written with the staleness rule as a single statement: add to
the total if its stamp is still inside the window, otherwise start again from
this amount. Reading and then deciding would be two statements with a gap, and
the gap is where a concurrent tick doubles or vanishes. The first tick for a
team and the thousandth are the same call, for the same reason.

The gate sits at the one moment it is allowed to speak — before a run starts,
never again. A limit refuses the next run and never interrupts one already
going; someone losing a session mid-game to a meter does not come back. Every
window is checked rather than the shortest, because they protect different
things over different spans.

The answer comes back with the created run rather than being thrown away: the
response carries where each window stands, what the account spends per second
now, and what one more run would cost. Every surface that can start a run has
to show that before the click, and a second call for it is a call nobody makes.
Asking twice would also let the number shown and the number billed disagree.

Accrual is wired to the run's own state transition, in the same transaction
that moves it. A session that went live without its meter starting is free
hardware; one that ended without its meter stopping bills forever. Both are
silent, so neither may be a second write that might not happen.
This commit is contained in:
Wanjohi
2026-09-19 00:19:39 +03:00
parent b819367a09
commit b4776fad2f
13 changed files with 4146 additions and 7 deletions

View File

@@ -0,0 +1,70 @@
-- What a team has spent, and the record it is derived from.
--
-- Two tables because they answer different questions and are written at very
-- different rates. `burn_counter` is one row per team holding a running total
-- per window; `burn_segment` is the append-only record those totals come from.
-- The totals are disposable and can be rebuilt from the record, which is what
-- makes zeroing one a support action rather than data loss.
--
-- **Each total is stored beside the time it began.** A total whose stamp has
-- fallen outside its window reads as zero, so a window rolls clear without
-- anything running -- no schedule to misfire, and no race between a reset and a
-- write arriving together. The same rule on the way in is a single statement:
-- add to the total if the stamp is still inside the window, otherwise start
-- again from this amount.
--
-- Counters live apart from `team` on purpose. This row is written every time
-- anything ticks, while `team` is read on a great many paths with nothing to do
-- with billing, and keeping the hot write off the row everyone reads is worth
-- the join.
--
-- A segment is one stretch of one run at one unchanging rate. Not a row per
-- session, because a session's rate does not survive its own lifetime -- a
-- second run changes what the account spends per second while the first is
-- still going. Not a row per event either, because burn accrues continuously
-- against an envelope that is held rather than per thing consumed. So the rate
-- is stamped at the moment it applied and never edited, and the number shown is
-- the number billed because no later pass could reach a different one.
--
-- `rate_milli` is the per-second rate times a thousand, so fractional factors
-- never make any of this floating point.
--
-- The partial unique index is load-bearing: two open segments for one session
-- would double-count every tick, for as long as both stayed open, silently.
--
-- Sessions and teams are `restrict` on the segment, because deleting a run or a
-- team must not erase what it cost. ref(d-0048)
CREATE TABLE "burn_counter" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"five_hour_usage" bigint DEFAULT 0 NOT NULL,
"five_hour_at" timestamp with time zone,
"seven_day_usage" bigint DEFAULT 0 NOT NULL,
"seven_day_at" timestamp with time zone,
"thirty_day_usage" bigint DEFAULT 0 NOT NULL,
"thirty_day_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "burn_segment" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"session_id" char(30) NOT NULL,
"rate_milli" integer NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"ended_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "burn_counter" ADD CONSTRAINT "burn_counter_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "burn_counter_team_unique" ON "burn_counter" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "burn_segment_team_idx" ON "burn_segment" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "burn_segment_session_idx" ON "burn_segment" USING btree ("session_id");--> statement-breakpoint
CREATE UNIQUE INDEX "burn_segment_one_open_per_session" ON "burn_segment" USING btree ("session_id") WHERE "burn_segment"."ended_at" is null;

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1789762221718,
"tag": "0015_organisation_owns_fleet_hardware",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789765075037,
"tag": "0016_burn_counters_and_rate_segments",
"breakpoints": true
}
]
}