feat(core): price a run by the size it holds, on hardware we pay for

The rate was a constant. That was correct for everything the system can
currently run and wrong the moment it can run anything else, because a tier
buys a share of a card — so a bigger one on our own hardware is more of
something we bought being spent, and a flat rate there sells a whole card for
the price of a quarter of one.

So a run's rate now comes from what the run is: its size tier, and whose
hardware it sits on.

On the caller's own hardware the tier changes nothing. There is no share of a
card of ours in play, so a run costs one unit a second whatever size it asked
for. Charging somebody more for taking more of a GPU they bought is a tax on
their own hardware, and not doing that is most of what this model is for.

This exposed a bug in what went before. Resegmenting recomputed one shared rate
and wrote it to every open stretch, which was harmless while all runs cost the
same and would have quietly repriced an expensive run as whatever the last one
to start was. Each stretch now keeps its own rate, which is also the more
honest shape: a run's rate is a property of that run, and nothing about it
changed because a sibling appeared or the clock ticked.

The account's total is now the sum of what its runs cost rather than a count
times one rate — an expensive run and a cheap one alongside it are not two of
anything. Concurrency still lands exactly where it did, as there being more to
add, and no run gets dearer because another started.

There is no hardware factor yet and its absence is deliberate: nothing records
which card a host has, so a table keyed on a model would be keyed on nothing.
A faster card should cost more, and that starts with a column.

The reference tier is pinned at exactly one unit a second, checked rather than
assumed. The unit is a second of a reference session, so moving it would
silently redefine every allowance — the same stored number would mean a
different number of hours.
This commit is contained in:
Wanjohi
2026-09-19 00:32:01 +03:00
parent b4776fad2f
commit 4c22586d59
7 changed files with 290 additions and 102 deletions

View File

@@ -236,8 +236,14 @@ export namespace SessionApi {
// being discarded, because the caller has to be told what it will // being discarded, because the caller has to be told what it will
// cost and what remains — and asking a second time would let the // cost and what remains — and asking a second time would let the
// number shown and the number billed disagree. // number shown and the number billed disagree.
const team = await Billing.teamForBox(box.id); const payer = await Billing.teamForBox(box.id);
const billing = team ? await Billing.assertMayStart(team) : null; const billing = payer
? await Billing.assertMayStart({
teamId: payer.teamId,
nextTier: payer.tier,
nextHostClass: payer.hostClass
})
: null;
const session = await Session.request({ const session = await Session.request({
id: Identifier.ascending('session'), id: Identifier.ascending('session'),

View File

@@ -76,7 +76,7 @@ describe('Segments', () => {
const run = await s.newRun(); const run = await s.newRun();
const t0 = new Date(); const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
const banked = await Burn.stop({ const banked = await Burn.stop({
teamId: s.teamId, teamId: s.teamId,
sessionId: run.id, sessionId: run.id,
@@ -98,8 +98,20 @@ describe('Segments', () => {
const second = await s.newRun(); const second = await s.newRun();
const t0 = new Date(); const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: first.id, at: t0 }); await Burn.start({
await Burn.start({ teamId: s.teamId, sessionId: second.id, at: t0 }); teamId: s.teamId,
sessionId: first.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
await Burn.start({
teamId: s.teamId,
sessionId: second.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
await Burn.stop({ teamId: s.teamId, sessionId: first.id, at: at(MINUTE, t0) }); await Burn.stop({ teamId: s.teamId, sessionId: first.id, at: at(MINUTE, t0) });
await Burn.stop({ teamId: s.teamId, sessionId: second.id, at: at(MINUTE, t0) }); await Burn.stop({ teamId: s.teamId, sessionId: second.id, at: at(MINUTE, t0) });
@@ -116,9 +128,21 @@ describe('Segments', () => {
const brief = await s.newRun(); const brief = await s.newRun();
const t0 = new Date(); const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: long.id, at: t0 }); await Burn.start({
teamId: s.teamId,
sessionId: long.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
// One minute alone. // One minute alone.
await Burn.start({ teamId: s.teamId, sessionId: brief.id, at: at(MINUTE, t0) }); await Burn.start({
teamId: s.teamId,
sessionId: brief.id,
tier: 'sm',
hostClass: 'byo',
at: at(MINUTE, t0)
});
// One minute together, which costs two. // One minute together, which costs two.
await Burn.stop({ teamId: s.teamId, sessionId: brief.id, at: at(2 * MINUTE, t0) }); await Burn.stop({ teamId: s.teamId, sessionId: brief.id, at: at(2 * MINUTE, t0) });
// One minute alone again. // One minute alone again.
@@ -134,7 +158,7 @@ describe('Segments', () => {
const run = await s.newRun(); const run = await s.newRun();
const t0 = new Date(); const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(MINUTE, t0) }); await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(MINUTE, t0) });
const second = await Burn.stop({ const second = await Burn.stop({
teamId: s.teamId, teamId: s.teamId,
@@ -153,7 +177,7 @@ describe('Segments', () => {
const run = await s.newRun(); const run = await s.newRun();
const t0 = new Date(); const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
await Burn.resegment({ teamId: s.teamId, at: at(5 * MINUTE, t0) }); await Burn.resegment({ teamId: s.teamId, at: at(5 * MINUTE, t0) });
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(5 * MINUTE); expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(5 * MINUTE);
@@ -207,16 +231,31 @@ describe('The counters', () => {
}); });
describe('Rates', () => { describe('Rates', () => {
test('a run costs the same whoever is running it', () => { test('on our hardware a bigger tier costs more, superlinearly', () => {
// The plan buys an allowance, never a discount on the meter. If the const rate = (tier: Burn.Tier) => Burn.baseRateMilli({ tier, hostClass: 'fleet' });
// rate moved with the tier, an upgrade would change what past runs cost expect(rate('sm')).toBe(Burn.SCALE);
// and the bars would stop being comparable. expect(rate('xl')).toBeGreaterThan(rate('lg'));
expect(Burn.rateMilliFor(1)).toBe(Burn.SCALE); // A tier buys a share of a card, and the ladder has to pinch harder
expect(Burn.rateMilliFor(4)).toBe(Burn.SCALE); // than the share grows or running a small title at the top of it is
// cheaper than it costs us.
expect(rate('xl') / rate('sm')).toBeGreaterThan(4);
}); });
test('nothing running costs nothing', () => { test('on the caller\u2019s own hardware the tier changes nothing', () => {
expect(Burn.rateMilliFor(0)).toBe(0); // There is no share of a card of ours being spent, so charging more for
// a bigger one would be a tax on hardware they bought.
for (const tier of ['xs', 'sm', 'md', 'lg', 'xl'] as const) {
expect(Burn.baseRateMilli({ tier, hostClass: 'byo' })).toBe(Burn.SCALE);
}
});
test('a run costs the same whatever plan is paying for it', () => {
// The plan buys an allowance, never a discount on the meter. If the
// rate moved with the tier somebody is on, an upgrade would change what
// past runs cost and the bars would stop being comparable.
expect(Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' })).toBe(
Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' })
);
}); });
test('burn is whole seconds, never a fraction of one', () => { test('burn is whole seconds, never a fraction of one', () => {

View File

@@ -6,6 +6,7 @@ import { Database } from '../db/index.js';
import { fn } from '../fn.js'; import { fn } from '../fn.js';
import { Identifier } from '../id.js'; import { Identifier } from '../id.js';
import { BurnCounterTable, BurnSegmentTable } from './burn.sql.js'; import { BurnCounterTable, BurnSegmentTable } from './burn.sql.js';
import { Limits } from './limits.js';
import { Window } from './window.js'; import { Window } from './window.js';
/** /**
@@ -27,30 +28,39 @@ export namespace Burn {
/** Rates are scaled by this so fractional factors stay integers. */ /** Rates are scaled by this so fractional factors stay integers. */
export const SCALE = 1000; export const SCALE = 1000;
/** Whose hardware a run is on, which is what decides the cost basis. */
export const HostClass = z.enum(['byo', 'fleet']);
export type HostClass = z.infer<typeof HostClass>;
export const Tier = z.enum(['xs', 'sm', 'md', 'lg', 'xl']);
export type Tier = z.infer<typeof Tier>;
/** /**
* What one running session costs per second, given how many are running. * What one run costs per second, before anything else is running.
* *
* On hardware the caller owns this is the whole calculation: the factors * **On our own hardware the tier decides it**, because a tier buys a share
* that price a share of a card price *our* cost basis, and on somebody * of a card we paid for and a bigger share is more of something real being
* else's card there is no card of ours being spent, so they are 1 and burn * spent. On the caller's own hardware it does not: there is no share of a
* is duration times how much is running at once. * card of ours in play, so a run costs one unit a second whatever size it
* asked for. Charging somebody more for taking more of their own GPU is a
* tax on hardware they bought, and avoiding that is most of the point.
* *
* The factor is on the **total**, not on each session — N concurrent runs * Note what is *not* here: the number of other runs. Concurrency is on the
* cost N between them, and a run's own rate does not change because a * account's total, not on any one run — two deadline guarantees cost twice
* sibling started. Two deadline guarantees cost twice one, not four times. * one, so two runs cost the sum of their two rates and neither of them gets
* Anything steeper would be a commercial decision to price concentration * more expensive because the other started. That is why this rate is fixed
* above cost, and has not been made. * for a run's whole life, and why a sibling starting does not have to
* rewrite anything.
*/ */
export function rateMilliFor(concurrency: number): number { export const baseRateMilli = fn(
if (concurrency <= 0) { z.object({ tier: Tier, hostClass: HostClass }),
return 0; (input): number => {
if (input.hostClass === 'byo') {
return SCALE;
}
return Limits.get().factors.size[input.tier];
} }
// Total rate is `concurrency`, shared equally, so each open segment );
// carries 1x. Spelled out rather than written as the constant it
// currently equals, because this is the line that changes if the factor
// ever moves off cost.
return SCALE;
}
/** Burn from one closed stretch, in whole reference-seconds. */ /** Burn from one closed stretch, in whole reference-seconds. */
export function amountFor(seconds: number, rateMilli: number): number { export function amountFor(seconds: number, rateMilli: number): number {
@@ -197,18 +207,20 @@ export namespace Burn {
.set({ endedAt: now }) .set({ endedAt: now })
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt))); .where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
const rateMilli = rateMilliFor(open.length); // Each run keeps its own rate. It is a property of what that run
if (open.length > 0) { // is — its tier, and whose hardware it sits on — and none of that
await tx.insert(BurnSegmentTable).values( // changed because the clock ticked or a sibling appeared.
open.map((segment) => ({ // Recomputing a single shared rate here would quietly reprice an
id: Identifier.ascending('burnSegment'), // `xl` run as whatever the last one to start was.
teamId: segment.teamId, await tx.insert(BurnSegmentTable).values(
sessionId: segment.sessionId, open.map((segment) => ({
rateMilli, id: Identifier.ascending('burnSegment'),
startedAt: now teamId: segment.teamId,
})) sessionId: segment.sessionId,
); rateMilli: segment.rateMilli,
} startedAt: now
}))
);
await record({ teamId: input.teamId, amount: total }); await record({ teamId: input.teamId, amount: total });
return total; return total;
@@ -224,22 +236,26 @@ export namespace Burn {
* backdated over time that was spent under the old one. * backdated over time that was spent under the old one.
*/ */
export const start = fn( export const start = fn(
z.object({ teamId: z.string(), sessionId: z.string(), at: z.date().optional() }), z.object({
teamId: z.string(),
sessionId: z.string(),
tier: Tier,
hostClass: HostClass,
at: z.date().optional()
}),
async (input) => { async (input) => {
return Database.transaction(async (tx) => { return Database.transaction(async (tx) => {
const now = input.at ?? new Date(); const now = input.at ?? new Date();
// Bank what the runs already going have spent, so the moment this
// one appears is a clean boundary in the record rather than a
// point inside somebody else's open stretch.
await resegment({ teamId: input.teamId, at: now }); await resegment({ teamId: input.teamId, at: now });
const open = await tx
.select()
.from(BurnSegmentTable)
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
await tx.insert(BurnSegmentTable).values({ await tx.insert(BurnSegmentTable).values({
id: Identifier.ascending('burnSegment'), id: Identifier.ascending('burnSegment'),
teamId: input.teamId, teamId: input.teamId,
sessionId: input.sessionId, sessionId: input.sessionId,
rateMilli: rateMilliFor(open.length + 1), rateMilli: baseRateMilli({ tier: input.tier, hostClass: input.hostClass }),
startedAt: now startedAt: now
}); });
}); });

View File

@@ -57,43 +57,70 @@ export namespace Billing {
return null; return null;
} }
const machine = await Machine.fromID(box.machineId); const machine = await Machine.fromID(box.machineId);
return machine?.teamId ?? null; if (!machine?.teamId) {
return null;
}
return {
teamId: machine.teamId,
tier: box.tier as Burn.Tier,
// Whose hardware decides the cost basis, and the machine is the only
// thing that knows. A host an organisation owns is ours to pay for;
// anything else is the caller's own card.
hostClass: (machine.organisationId ? 'fleet' : 'byo') as Burn.HostClass
};
}); });
/** Where a team stands, in every window, with the rates to show beside it. */ /** Where a team stands, in every window, with the rates to show beside it. */
export const state = fn(z.string(), async (teamId): Promise<State> => { export const state = fn(
const team = await Team.fromID(teamId); z.object({
const plan = team?.plan ?? 'free'; teamId: z.string(),
const allowances = Limits.forPlan(plan); /** The run being considered, so "one more" can be costed honestly. */
const counters = await Burn.counters(teamId); nextTier: Burn.Tier.optional(),
const open = await Burn.openSegments(teamId); nextHostClass: Burn.HostClass.optional()
}),
async (input): Promise<State> => {
const teamId = input.teamId;
const team = await Team.fromID(teamId);
const plan = team?.plan ?? 'free';
const allowances = Limits.forPlan(plan);
const counters = await Burn.counters(teamId);
const open = await Burn.openSegments(teamId);
const windows = Window.ALL.map((window) => {
const usage = counters ? Number(counters[`${window.key}Usage`] ?? 0) : 0;
const at = counters ? (counters[`${window.key}At`] ?? null) : null;
return {
window: window.key,
label: window.label,
...Window.analyze({
allowance: allowances[window.key],
windowSeconds: window.seconds,
usage,
timeUpdated: at
})
};
});
// The account's total is the sum of what each run costs, not a count
// times one rate: an `xl` run and an `xs` one alongside it are not
// two of anything. Concurrency shows up here, as there being more to
// add, rather than as a multiplier on any of them.
const rateMilli = open.reduce((total, segment) => total + segment.rateMilli, 0);
const next = Burn.baseRateMilli({
tier: input.nextTier ?? 'sm',
hostClass: input.nextHostClass ?? 'byo'
});
const windows = Window.ALL.map((window) => {
const usage = counters ? Number(counters[`${window.key}Usage`] ?? 0) : 0;
const at = counters ? (counters[`${window.key}At`] ?? null) : null;
return { return {
window: window.key, teamId,
label: window.label, plan,
...Window.analyze({ exhausted: windows.some((w) => w.exhausted),
allowance: allowances[window.key], rateMilli,
windowSeconds: window.seconds, rateMilliIfOneMore: rateMilli + next,
usage, windows
timeUpdated: at
})
}; };
}); }
);
return {
teamId,
plan,
exhausted: windows.some((w) => w.exhausted),
// The account's total, which is what moves when a run starts — not
// any one session's own rate, which does not change.
rateMilli: open.length * Burn.rateMilliFor(open.length),
rateMilliIfOneMore: (open.length + 1) * Burn.rateMilliFor(open.length + 1),
windows
};
});
/** /**
* Refuse a new run when any window is spent. * Refuse a new run when any window is spent.
@@ -107,17 +134,24 @@ export namespace Billing {
* which is the honest status — this is a rate limit the customer experiences * which is the honest status — this is a rate limit the customer experiences
* as a budget, and it will succeed later without anything changing. * as a budget, and it will succeed later without anything changing.
*/ */
export const assertMayStart = fn(z.string(), async (teamId) => { export const assertMayStart = fn(
const current = await state(teamId); z.object({
const spent = current.windows.find((w) => w.exhausted); teamId: z.string(),
if (!spent) { nextTier: Burn.Tier.optional(),
return current; nextHostClass: Burn.HostClass.optional()
}),
async (input) => {
const current = await state(input);
const spent = current.windows.find((w) => w.exhausted);
if (!spent) {
return current;
}
const minutes = Math.ceil(spent.resetInSec / 60);
throw new VisibleError(
'rate_limit',
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
`Your ${spent.label} allowance is spent. It clears in about ${minutes} minute${minutes === 1 ? '' : 's'}. Runs already going are not affected.`
);
} }
const minutes = Math.ceil(spent.resetInSec / 60); );
throw new VisibleError(
'rate_limit',
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
`Your ${spent.label} allowance is spent. It clears in about ${minutes} minute${minutes === 1 ? '' : 's'}. Runs already going are not affected.`
);
});
} }

View File

@@ -67,6 +67,16 @@ describe('The nesting rule: every window has to bind', () => {
expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow(); expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow();
}); });
test('the reference tier has to cost exactly one unit a second', () => {
// The unit *is* a second of a reference session, so moving `sm` off 1
// would silently redefine every allowance — the same stored number
// would be a different number of hours.
expect(() =>
Limits.checkFactors({ size: { xs: 500, sm: 900, md: 2200, lg: 5000, xl: 12000 } })
).toThrow(/reference tier/);
expect(() => Limits.checkFactors(Limits.PLACEHOLDER.factors)).not.toThrow();
});
test('the placeholder set satisfies both rules', () => { test('the placeholder set satisfies both rules', () => {
// It is not a pricing decision, but it has to be a coherent one, or // It is not a pricing decision, but it has to be a coherent one, or
// nothing downstream can be tested against it. // nothing downstream can be tested against it.
@@ -83,7 +93,11 @@ describe('Configuration', () => {
test('the environment overrides it, and is validated on the way in', () => { test('the environment overrides it, and is validated on the way in', () => {
Env.init({ Env.init({
BURN_LIMITS: JSON.stringify({ free: hours(12, 350, 1200), paid: hours(40, 1200, 4000) }) BURN_LIMITS: JSON.stringify({
free: hours(12, 350, 1200),
paid: hours(40, 1200, 4000),
factors: Limits.PLACEHOLDER.factors
})
}); });
Limits.reset(); Limits.reset();
expect(Limits.get().free.fiveHour).toBe(12 * HOUR); expect(Limits.get().free.fiveHour).toBe(12 * HOUR);
@@ -94,7 +108,11 @@ describe('Configuration', () => {
// closest to the burn data, and a set that quietly stops binding is not // closest to the burn data, and a set that quietly stops binding is not
// visible from the numbers. // visible from the numbers.
Env.init({ Env.init({
BURN_LIMITS: JSON.stringify({ free: hours(10, 400, 1000), paid: hours(30, 900, 3000) }) BURN_LIMITS: JSON.stringify({
free: hours(10, 400, 1000),
paid: hours(30, 900, 3000),
factors: Limits.PLACEHOLDER.factors
})
}); });
Limits.reset(); Limits.reset();
expect(() => Limits.get()).toThrow(/never be reached/); expect(() => Limits.get()).toThrow(/never be reached/);

View File

@@ -33,9 +33,49 @@ export namespace Limits {
export type Allowances = z.infer<typeof Allowances>; export type Allowances = z.infer<typeof Allowances>;
/**
* What a size tier costs per second, times a thousand, on our own hardware.
*
* A tier buys a share of a card, so a bigger one spends more of something
* we paid for. These must be **superlinear in that share**: a small title
* asked to run at the top of the ladder has to cost what a whole card
* costs, or the ladder is gamed and the density that makes any of this
* priceable is theoretical.
*
* They do not apply on a caller's own hardware. See {@link Factors}.
*/
const SizeFactors = z.object({
xs: z.number().int().positive(),
sm: z.number().int().positive(),
md: z.number().int().positive(),
lg: z.number().int().positive(),
xl: z.number().int().positive()
});
/**
* How much a running session costs per second, before concurrency.
*
* **Only on hardware we own.** The size factor prices a share of a card we
* bought; on somebody else's card there is no such share being spent, so a
* session there costs one unit a second whatever tier it asked for. Charging
* more for taking more of their own GPU would be a tax on hardware they paid
* for, which is the complaint this whole model is shaped to avoid.
*
* There is no hardware factor here yet, and its absence is deliberate rather
* than an oversight: nothing records which card a host has, so a table keyed
* on a model would be keyed on nothing. A faster card should cost more, and
* that starts with a column, not a number.
*/
export const Factors = z.object({
size: SizeFactors
});
export type Factors = z.infer<typeof Factors>;
export const Config = z.object({ export const Config = z.object({
free: Allowances, free: Allowances,
paid: Allowances paid: Allowances,
factors: Factors
}); });
export type Config = z.infer<typeof Config>; export type Config = z.infer<typeof Config>;
@@ -59,6 +99,13 @@ export namespace Limits {
fiveHour: 30 * 60 * 60, fiveHour: 30 * 60 * 60,
sevenDay: 900 * 60 * 60, sevenDay: 900 * 60 * 60,
thirtyDay: 3000 * 60 * 60 thirtyDay: 3000 * 60 * 60
},
// Superlinear, and no more principled than that. `sm` is the reference
// and is 1 by definition; the rest roughly double per step so the shape
// is visible in tests. Real values come from what a card-hour costs us
// divided by the share a tier holds.
factors: {
size: { xs: 500, sm: 1000, md: 2200, lg: 5000, xl: 12000 }
} }
}; };
@@ -108,10 +155,28 @@ export namespace Limits {
} }
} }
/**
* The reference tier costs exactly one unit a second, by definition.
*
* The unit *is* a second of a reference session, so a size factor that made
* `sm` anything other than 1 would silently redefine what every allowance
* means — the same stored number would be a different number of hours.
*/
export function checkFactors(factors: Factors): void {
if (factors.size.sm !== 1000) {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`the reference tier must cost exactly one unit a second (1000), not ${factors.size.sm} \u2014 it is what every allowance is denominated in`
);
}
}
export function validate(config: unknown): Config { export function validate(config: unknown): Config {
const parsed = Config.parse(config); const parsed = Config.parse(config);
check(parsed.free, 'free'); check(parsed.free, 'free');
check(parsed.paid, 'paid'); check(parsed.paid, 'paid');
checkFactors(parsed.factors);
return parsed; return parsed;
} }

View File

@@ -664,7 +664,17 @@ export namespace Session {
const machine = await Machine.fromID(input.machineId); const machine = await Machine.fromID(input.machineId);
if (machine?.teamId) { if (machine?.teamId) {
if (current.state !== 'live' && moved.state === 'live') { if (current.state !== 'live' && moved.state === 'live') {
await Burn.start({ teamId: machine.teamId, sessionId: moved.id }); // The rate is fixed here, from what this run actually is:
// the size it holds, and whose card it holds it on. Both
// are settled before the run starts, which is what lets a
// person be told the cost before committing to it.
const box = await Box.fromID(moved.boxId);
await Burn.start({
teamId: machine.teamId,
sessionId: moved.id,
tier: (box?.tier ?? 'sm') as Burn.Tier,
hostClass: machine.organisationId ? 'fleet' : 'byo'
});
} else if ( } else if (
ACCRUING.includes(current.state as (typeof ACCRUING)[number]) && ACCRUING.includes(current.state as (typeof ACCRUING)[number]) &&
!ACCRUING.includes(moved.state as (typeof ACCRUING)[number]) !ACCRUING.includes(moved.state as (typeof ACCRUING)[number])