@volter/twin-fly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ export { handleFlyTwinRequest, flyTwinSnapshot, FLY_API_HOST, FLY_INTERNAL_API_HOST, FLY_RESOURCE_TYPES } from './fly-twin.js';
2
+ export type { FlyRequest, FlyResponse } from './fly-twin.js';
3
+ export { createFlyTwinFetch, createFlyTwinServer } from './fly-server.js';
4
+ export type { FlyTwinFetchOptions } from './fly-server.js';
5
+ export { resolveFlyRuntime } from './fly-runtime-choice.js';
6
+ export type { FlyServerRuntimeChoice, FlyLocalExecutionOptions } from './fly-runtime-choice.js';
7
+ export { containerSpecOf, FLY_MACHINE_STATES, flyRuntimeEnv, imageRefOf, machinePrivateIp, newInstanceId, newMachineId, newMachineName, newVolumeId, VIRTUAL_FLY_RUNTIME, } from './fly-machines.js';
8
+ export type { FlyContainerRuntime, FlyContainerSpec, FlyExecResult, FlyMachineEvent, FlyMachineState, FlyPortSpec, } from './fly-machines.js';
9
+ export { liveFlyExecute, mapApp, mapMachine, mapVolume, pullFlyApps, pullFlyMachines, pullFlyVolumes, syncFlyFromReal, flyExecuteOver, syncFlyFromRemote, performFlyAction, } from './fly-connector.js';
10
+ export type { FlyExecute, LiveFlyOptions } from './fly-connector.js';
11
+ export { FLY_BUDGET_CEILING, FLY_BUDGET_MAX_RETRY_AFTER_S, FLY_BUDGET_WINDOW_MS, FLY_CALL_WEIGHTS, FLY_RATE_BUDGET, FlyBudget, FlyBudgetError, flyBudgetPath, flyCallWeight, } from './fly-budget.js';
12
+ export type { FlyBudgetErrorKind, FlyBudgetOptions, FlyBudgetReservation, FlyBudgetSnapshot } from './fly-budget.js';
13
+ import { type TwinPack } from '@volter/world-core';
14
+ export declare const pack: TwinPack;
@@ -0,0 +1,117 @@
1
+ // @volter/twin-fly — the Fly.io MACHINES API twin (api.machines.dev/v1), built on the shared
2
+ // @volter/world-core kernel. The first REAL-EXECUTION-PLANE compute twin in the catalog: the control
3
+ // plane (apps / machines / volumes / secrets, machine lifecycle states + events, leases, wait)
4
+ // is a faithful, stateful local surface over kernel event-sourced state — and when a machine is
5
+ // created or started the twin ACTUALLY runs `config.image` as a real local Docker container
6
+ // through the injected `FlyContainerRuntime` seam (fly-docker.ts). The supabase doctrine at the
7
+ // compute layer: twin the control plane, run the real engine. Offline by default: the pure-ledger
8
+ // VIRTUAL runtime means every verify, test and gate runs with no Docker daemon anywhere.
9
+ //
10
+ // Bearer auth is faked-but-enforced. No real Fly is ever contacted (the connector's injected
11
+ // executor is the one live boundary, budget-guarded).
12
+ //
13
+ // (Conformance tooling lives in @volter/world-tooling, a dev dependency — not shipped here, and
14
+ // `./fly-conformance.ts` is NOT re-exported: cli.ts imports it lazily.)
15
+ export { handleFlyTwinRequest, flyTwinSnapshot, FLY_API_HOST, FLY_INTERNAL_API_HOST, FLY_RESOURCE_TYPES } from "./fly-twin.js";
16
+ export { createFlyTwinFetch, createFlyTwinServer } from "./fly-server.js";
17
+ export { resolveFlyRuntime } from "./fly-runtime-choice.js";
18
+ export { containerSpecOf, FLY_MACHINE_STATES, flyRuntimeEnv, imageRefOf, machinePrivateIp, newInstanceId, newMachineId, newMachineName, newVolumeId, VIRTUAL_FLY_RUNTIME, } from "./fly-machines.js";
19
+ // NB: `./fly-docker.ts` (the real execution plane) is deliberately NOT re-exported here: the
20
+ // host boundary resolves it lazily (`resolveFlyRuntime('docker')` in fly-runtime-choice.ts, dev plane), so `node:child_process` and the
21
+ // docker CLI wrapper never enter the runtime graph of a consumer that stays virtual. Import it
22
+ // by direct path if you genuinely want the docker runtime in-process.
23
+ export { liveFlyExecute, mapApp, mapMachine, mapVolume, pullFlyApps, pullFlyMachines, pullFlyVolumes, syncFlyFromReal, flyExecuteOver, syncFlyFromRemote, performFlyAction, } from "./fly-connector.js";
24
+ // The client-side rate budget — the fail-closed backstop `liveFlyExecute` routes every live
25
+ // request through. Exported so an operator can inspect spend; there is deliberately no export
26
+ // that disables the guard.
27
+ export { FLY_BUDGET_CEILING, FLY_BUDGET_MAX_RETRY_AFTER_S, FLY_BUDGET_WINDOW_MS, FLY_CALL_WEIGHTS, FLY_RATE_BUDGET, FlyBudget, FlyBudgetError, flyBudgetPath, flyCallWeight, } from "./fly-budget.js";
28
+ // Registry descriptor: the pack self-describes so tooling can discover it.
29
+ import { registerPack, referenceField } from '@volter/world-core';
30
+ import { FLY_RATE_BUDGET as RATE_BUDGET } from "./fly-budget.js";
31
+ import { performFlyAction as perform, syncFlyFromRemote as refresh } from "./fly-connector.js";
32
+ export const pack = {
33
+ vendor: 'fly',
34
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
35
+ // state system: perform one entry against Fly, refresh the root from it. Moved 2026-09-08. A performed
36
+ // machine-create provisions real, billable compute: the head performs only when an operator bound it to
37
+ // a real state system.
38
+ protocol: '2',
39
+ refresh: { every: '5m', onDemand: { atMost: '30s' } }, // the Machines API has no webhooks: polled, or on demand
40
+ stateSystem: { perform, refresh },
41
+ // the round trip: an app (its name is Fly's id — the first write may answer 422 name-taken on a branch that
42
+ // already holds it, and the round trip reads the LAST write's answer), then a volume in it
43
+ roundTrip: [
44
+ { method: 'POST', path: '/v1/apps', body: { app_name: 'round-trip', org_slug: 'personal' }, headers: { authorization: 'Bearer round-trip' } },
45
+ { method: 'POST', path: '/v1/apps/round-trip/volumes', body: { name: 'data', region: 'sjc', size_gb: 1 }, headers: { authorization: 'Bearer round-trip' } },
46
+ ],
47
+ // rule 5: a machine, a volume and a secret name their app; a machine's mount names its volume, and a volume
48
+ // names the machine it is attached to. When the head adopts Fly's id for any of them, the kernel resolves the
49
+ // referencing side first. The reference trip is a machine in the round trip's app (a mount would bind the one
50
+ // volume to the first machine — Fly attaches a volume once — so the trip that repeats names only its app).
51
+ referenceTrip: { method: 'POST', path: '/v1/apps/{{id}}/machines', body: { region: 'sjc', config: { image: 'nginx:1.27' } }, headers: { authorization: 'Bearer round-trip' } },
52
+ references: [
53
+ referenceField('machine', 'app_name', 'app'),
54
+ referenceField('volume', 'app_name', 'app'),
55
+ referenceField('secret', 'app_name', 'app'),
56
+ {
57
+ type: 'machine', to: 'volume',
58
+ key: (f) => { const mounts = f.config?.mounts; const v = mounts?.[0]?.volume; return typeof v === 'string' && v ? v : undefined; },
59
+ adopt: (f, vendorId) => { const config = (f.config ?? {}); const mounts = [...(config.mounts ?? [])]; if (mounts[0])
60
+ mounts[0] = { ...mounts[0], volume: vendorId }; return { config: { ...config, mounts } }; },
61
+ },
62
+ referenceField('volume', 'attached_machine_id', 'machine'),
63
+ ],
64
+ parityOrigin: 'http://twin',
65
+ shapeParity: 'held',
66
+ // the execution plane: real local Docker containers live outside the world store, by declaration
67
+ engine: { module: 'fly-docker', note: 'real local Docker containers: a started machine runs config.image; the tree references the container by ref' },
68
+ // The SAME object fly-budget.ts declares at module load — one source of truth, so registering
69
+ // the pack and importing the connector can never arm two different ceilings.
70
+ rateBudget: RATE_BUDGET,
71
+ transport: 'rest',
72
+ archetype: 'engine-control',
73
+ bin: 'world-fly',
74
+ resources: ['app', 'machine', 'volume', 'secret'],
75
+ specSource: "Fly.io Machines API — first-party OpenAPI (docs.machines.dev/swagger/doc.json, 'Machines API 1.0') "
76
+ + 'cross-checked against superfly/fly-go (flyctl\'s own client); fly.io/docs/machines/api for hosts, '
77
+ + 'response codes and rate limits',
78
+ description: 'Fly.io Machines API twin — apps/machines/volumes/secrets CRUD, the machine lifecycle '
79
+ + '(states + ledgered events, wait, leases, metadata, exec), connector pull. The execution '
80
+ + 'plane is REAL local Docker via the injected FlyContainerRuntime seam: created/started '
81
+ + 'machines genuinely run config.image as local containers (virtual pure-ledger runtime by '
82
+ + 'default, so everything works offline).',
83
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
84
+ // 2026-08-31).
85
+ //
86
+ // Fly publishes NO first-party npm client (flyctl embeds the Go client superfly/fly-go; the
87
+ // docs' contract is "point FLY_API_HOSTNAME at the base URL and speak REST"), so the canonical
88
+ // integration is a hand-written fetch wrapper and the credential env var is the PRIMARY scan
89
+ // signal: FLY_API_TOKEN — the exact var Fly's own docs export next to FLY_API_HOSTNAME — stems
90
+ // to `fly` under _API_TOKEN. FLY_API_HOSTNAME itself deliberately does NOT stem: _HOSTNAME is
91
+ // no credential/endpoint suffix, so it never reaches the lookup. `fly-admin` is the one real
92
+ // npm client of the modeled surface worth claiming (Supabase's maintained TS client for
93
+ // api.machines.dev; its base URL is a constructor option, so pointing it at the twin is
94
+ // configuration). No scope to claim — Fly owns no npm scope for API clients.
95
+ adoption: {
96
+ // No official Python SDK - flyctl and `fly-admin` (npm) are the vendor's clients. The community
97
+ // `fly-python-sdk` is a single-author wrapper with negligible adoption, deliberately unclaimed.
98
+ pypi: [],
99
+ sdks: ['fly-admin'], envStems: ['FLY'],
100
+ },
101
+ // TWO base hosts, both from Fly's own docs (fly.io/docs/machines/api/working-with-machines-api
102
+ // "API addresses"):
103
+ // • api.machines.dev — the public base URL every out-of-mesh client uses (flyctl's flaps
104
+ // transport, fly-admin, plain fetch with FLY_API_HOSTNAME).
105
+ // • _api.internal — the internal base URL (port 4280) reachable only inside a Fly
106
+ // WireGuard mesh; claimed so code written for in-mesh deployment redirects to the twin
107
+ // instead of failing DNS on a laptop.
108
+ // fly.io itself (the dashboard/website) and api.fly.io (the legacy GraphQL API this pack does
109
+ // not model) are deliberately unclaimed: strict egress should fail closed on a surface the twin
110
+ // does not serve rather than let a half-modeled flow through.
111
+ hosts: [{ host: 'api.machines.dev' }, { host: '_api.internal' }],
112
+ // Machines API clients hit https://api.machines.dev/v1/... (or http://_api.internal:4280 from
113
+ // inside a Fly private network); the dev proxy forwards the /v1/ prefix to the twin.
114
+ browserRouting: { apiPathPrefix: '/v1/', loaderHost: 'https://api.machines.dev' },
115
+ };
116
+ // registered at import: the kernel learns the pack's state system and its references (protocol 2)
117
+ registerPack(pack);
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@volter/twin-fly",
3
+ "version": "0.1.0",
4
+ "description": "Local Fly.io Machines API twin (api.machines.dev v1: apps/machines/volumes/secrets CRUD, machine lifecycle states+events, leases, wait) whose execution plane is REAL local Docker — control plane twinned, containers genuinely run. Built on @volter/world-core.",
5
+ "author": "Volter (https://github.com/volter-ai)",
6
+ "license": "Apache-2.0",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE",
11
+ "!**/*.test.ts",
12
+ "!**/*.test.tsx",
13
+ "dist"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/volter-ai/twin.git",
18
+ "directory": "packages/twin/fly"
19
+ },
20
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/fly#readme",
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/src/index.d.ts",
25
+ "default": "./dist/src/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "world-fly": "dist/src/cli.js"
30
+ },
31
+ "scripts": {
32
+ "test": "bun test src/*.test.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "node ../../../scripts/publish/build.mjs",
35
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
36
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
37
+ },
38
+ "peerDependencies": {
39
+ "@volter/world-core": "2.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "^1.2.20",
43
+ "@types/node": "^24.0.0",
44
+ "@volter/world-core": "2.0.0",
45
+ "@volter/world-tooling": "0.1.0",
46
+ "typescript": "^5.9.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.3"
50
+ }
51
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // world-fly CLI: serve the Machines API twin, or run conformance. There is no `mirror` command —
3
+ // Fly is an API-first vendor with no product UI to mirror (see README `### No UI mirror`).
4
+ //
5
+ // The EXECUTION PLANE is explicit: `serve` defaults to the pure-ledger VIRTUAL runtime (offline,
6
+ // nothing runs); `serve --local-execution` runs real local Machines and FAILS LOUDLY if local
7
+ // execution is unavailable — never a silent downgrade a caller could mistake for real machines.
8
+ import { hasFlag, optionValue } from '@volter/world-core/args';
9
+ import { createFlyTwinServer } from './fly-server.ts';
10
+ import { resolveFlyRuntime } from './fly-runtime-choice.ts';
11
+
12
+ const [cmd, ...rest] = process.argv.slice(2);
13
+
14
+ /** A malformed port must FAIL, not silently become an ephemeral one (the smtp `--port 1O25`
15
+ * lesson). `0` is accepted and means "ephemeral". */
16
+ function portOption(flag: string): number | undefined {
17
+ const raw = optionValue(rest, flag);
18
+ if (raw === undefined || raw === '') return undefined;
19
+ const value = Number(raw);
20
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
21
+ process.stderr.write(`world-fly: ${flag} must be an integer 0-65535 (got ${JSON.stringify(raw)})\n`);
22
+ process.exit(2);
23
+ }
24
+ return value === 0 ? undefined : value;
25
+ }
26
+
27
+ /** A non-negative whole number of MiB, or undefined when the flag is absent. Malformed FAILS
28
+ * (the `--port 1O25` lesson) — a World that asked for a different admission floor and silently
29
+ * got the default would be told nothing. */
30
+ function reserveOption(flag: string): number | undefined {
31
+ const raw = optionValue(rest, flag);
32
+ if (raw === undefined || raw === '') return undefined;
33
+ const value = Number(raw);
34
+ if (!Number.isInteger(value) || value < 0) {
35
+ process.stderr.write(`world-fly: ${flag} must be a non-negative integer number of MiB (got ${JSON.stringify(raw)})\n`);
36
+ process.exit(2);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ const port = portOption('--port');
42
+ const root = optionValue(rest, '--root') || undefined;
43
+ const readOnly = hasFlag(rest, '--read-only');
44
+ const localExecution = hasFlag(rest, '--local-execution') || hasFlag(rest, '--docker');
45
+ // The writable-storage RESERVE admission keeps free beyond a machine's own need — a mechanism
46
+ // value the World configures (default 2048 MiB), never a constant compiled into the runtime.
47
+ const storageReserveMiB = reserveOption('--storage-reserve-mib');
48
+
49
+ if (cmd === 'serve') {
50
+ const server = await createFlyTwinServer({
51
+ cleanupOwnedOnStart: process.env.VOLTER_WORLD_NAME !== undefined,
52
+ readOnly,
53
+ runtime: await resolveFlyRuntime(localExecution ? 'docker' : 'virtual', root, {
54
+ ...(storageReserveMiB !== undefined ? { storageReserveMiB } : {}),
55
+ }),
56
+ ...(root ? { root } : {}),
57
+ ...(port ? { port } : {}),
58
+ });
59
+ process.stdout.write(`machine provider${readOnly ? ' [read-only]' : ''} ready on http://127.0.0.1:${server.port}\n`);
60
+ const signal = await new Promise<'SIGINT' | 'SIGTERM'>((resolveSignal) => {
61
+ process.once('SIGINT', () => resolveSignal('SIGINT'));
62
+ process.once('SIGTERM', () => resolveSignal('SIGTERM'));
63
+ });
64
+ try {
65
+ await server.stop();
66
+ } catch (error) {
67
+ process.stderr.write(`machine provider: ${signal} cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
68
+ process.exitCode = 1;
69
+ }
70
+ } else if (cmd === 'conformance') {
71
+ const { checkFlyConformance } = await import('./fly-conformance.ts'); // lazy — dev-only
72
+ const report = await checkFlyConformance();
73
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
74
+ if (!report.ok) process.exitCode = 1;
75
+ } else {
76
+ process.stdout.write('Usage: world-fly serve|conformance [--port N] [--root DIR] [--read-only] [--local-execution] [--storage-reserve-mib N]\n');
77
+ }
@@ -0,0 +1,121 @@
1
+ // Fly.io's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the vendor-bound
2
+ // bindings `liveFlyExecute` (fly-connector.ts) routes every live Machines API call through. The
3
+ // MECHANISM — the durable token-keyed ledger, the rolling window, reserve-under-lock, the
4
+ // `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger — lives ONCE in the vendor-agnostic
5
+ // kernel (`@volter/world-core` → rateBudget.ts). This module is modeled on supabase-budget.ts (the
6
+ // same shape: the pack builds its own HTTP executor, so the guard sits inside the one function
7
+ // that issues a live request).
8
+ //
9
+ // ── WHAT FLY ACTUALLY PUBLISHES (live-fetched 2026-08-20,
10
+ // fly.io/docs/machines/api/working-with-machines-api "Rate Limits") ───────────────────────
11
+ // "Machines API rate limits apply per-action, per-machine and are scoped per identifier
12
+ // (Machine ID or App ID). The limit is 1 request, per second, per action — with a short-term
13
+ // burst limit up to 3 req/s, per action. This applies to all actions except Get Machine which
14
+ // is 5 req/s, with a short-term burst limit up to 10 req/s. Additionally, app deletions are
15
+ // limited to 100 per minute."
16
+ //
17
+ // Those are PER-ACTION PER-RESOURCE limits; Fly publishes NO account-wide scalar, and this
18
+ // ledger is account-wide (one ledger per token) — a shape mismatch this declaration resolves in
19
+ // the tight direction: the ceiling is pinned to the kernel's conservative fallback
20
+ // (DEFAULT_RATE_BUDGET: 60 weighted units / 60s at weight 2 = 30 calls/minute — a rate the
21
+ // vendor's own per-action allowance of 60/min/action trivially admits), rather than multiplying
22
+ // per-action numbers into an invented account-wide figure. Machine CREATION is priced heavier
23
+ // (weight 3) than reads/actions — creation provisions real billable infrastructure and is the
24
+ // call a runaway loop repeats; pricing it at 3 means a creation-only loop is stopped after 20
25
+ // creates in a minute, tighter than Fly's own 60/min-per-app. Same-burst-as-fallback, so no
26
+ // VENDOR_BURST_ANCHOR entry is needed (the anchor binds only out-bursting declarations).
27
+ import {
28
+ declareRateBudget,
29
+ rateBudgetPath,
30
+ rateBudgetWeight,
31
+ RateBudget,
32
+ type RateBudgetDeclaration,
33
+ type RateBudgetOptions,
34
+ type RateBudgetReservation,
35
+ type RateBudgetSnapshot,
36
+ } from '@volter/world-core';
37
+
38
+ const VENDOR = 'fly';
39
+
40
+ /** Rolling window, in ms (the kernel fallback's window — a window may never be shorter). */
41
+ export const FLY_BUDGET_WINDOW_MS = 60_000;
42
+
43
+ /** Weighted units allowed inside one window. Pinned to the kernel fallback — see the header. */
44
+ export const FLY_BUDGET_CEILING = 60;
45
+
46
+ /** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
47
+ export const FLY_BUDGET_MAX_RETRY_AFTER_S = 300;
48
+
49
+ /** Per-call cost. `create` is machine/volume/app creation (billable provisioning); `other` is
50
+ * everything else (reads and lifecycle actions). */
51
+ export const FLY_CALL_WEIGHTS = {
52
+ create: 3,
53
+ other: 2,
54
+ } as const;
55
+
56
+ /** THE PACK'S DECLARATION — pure data, the only Fly-specific thing in the whole budget.
57
+ * Calls are priced by a `METHOD /path` key (see flyCallWeight); the create rule matches the
58
+ * three provisioning POSTs (apps, machines, volumes collection endpoints). */
59
+ export const FLY_RATE_BUDGET: RateBudgetDeclaration = {
60
+ windowMs: FLY_BUDGET_WINDOW_MS,
61
+ ceiling: FLY_BUDGET_CEILING,
62
+ defaultWeight: FLY_CALL_WEIGHTS.other,
63
+ maxRetryAfterSeconds: FLY_BUDGET_MAX_RETRY_AFTER_S,
64
+ // First-match-wins. POST on a collection endpoint (…/apps, …/machines, …/volumes) is a
65
+ // billable CREATE; the $ anchors keep one-resource POSTs (…/machines/{id}, lifecycle actions)
66
+ // at the default price.
67
+ rules: [
68
+ { match: '^POST /v1/apps$', weight: FLY_CALL_WEIGHTS.create },
69
+ { match: '^POST /v1/apps/[^/]+/machines$', weight: FLY_CALL_WEIGHTS.create },
70
+ { match: '^POST /v1/apps/[^/]+/volumes$', weight: FLY_CALL_WEIGHTS.create },
71
+ ],
72
+ reason:
73
+ 'Fly publishes PER-ACTION, PER-RESOURCE Machines API limits (fly.io/docs/machines/api/working-with-machines-api, ' +
74
+ 'live-fetched 2026-08-20): 1 req/s per action per machine with short bursts to 3 req/s; Get Machine 5 req/s ' +
75
+ 'bursting to 10; app deletions 100/min. There is NO published account-wide scalar, and this ledger is ' +
76
+ 'account-wide (per token), so the ceiling is pinned to the kernel fallback (60 units / 60s at weight 2 = ' +
77
+ '30 calls/min — a rate the per-action allowance of 60/min/action trivially admits) rather than multiplying ' +
78
+ 'per-action numbers into an invented account figure. Creation POSTs (apps/machines/volumes) cost 3: they ' +
79
+ 'provision real billable infrastructure, so a runaway create loop is refused after 20/min, tighter than ' +
80
+ "Fly's own 60 creates/min/app. Window and burst equal the fallback's, so nothing here is more permissive " +
81
+ 'than an undeclared vendor already gets.',
82
+ };
83
+
84
+ // Declared at module load, so merely importing this module (which fly-connector.ts does) is
85
+ // enough to arm the real ceiling.
86
+ declareRateBudget(VENDOR, FLY_RATE_BUDGET);
87
+
88
+ /** Price one Machines API call by `METHOD /path` (query string stripped by the caller). */
89
+ export function flyCallWeight(method: string, path: string): number {
90
+ return rateBudgetWeight(VENDOR, `${method.toUpperCase()} ${path}`);
91
+ }
92
+
93
+ /** Where Fly's ledger lives. Token-keyed and cwd-independent by default (Fly limits per
94
+ * identifier under one account/token, so a cwd-scoped ledger would hand the same token a fresh
95
+ * allowance in every checkout, worktree and CI matrix leg); pass `root` for world-scoped
96
+ * accounting. */
97
+ export function flyBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
98
+ const o = typeof opts === 'string' ? { root: opts } : opts;
99
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
100
+ // redirect this pack's ledger to another vendor's file.
101
+ return rateBudgetPath({ ...o, vendor: VENDOR });
102
+ }
103
+
104
+ /** Construction options for Fly's budget. The vendor is fixed; everything else may only TIGHTEN. */
105
+ export type FlyBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
106
+
107
+ /**
108
+ * Fly's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
109
+ * not an alias, so `budget instanceof FlyBudget` means "a budget that accounts against this
110
+ * vendor's ledger under this vendor's ceiling".
111
+ */
112
+ export class FlyBudget extends RateBudget {
113
+ constructor(opts: FlyBudgetOptions = {}) {
114
+ super({ ...opts, vendor: VENDOR });
115
+ }
116
+ }
117
+
118
+ export type { RateBudgetErrorKind as FlyBudgetErrorKind } from '@volter/world-core';
119
+ export { RateBudgetError as FlyBudgetError } from '@volter/world-core';
120
+ export type FlyBudgetReservation = RateBudgetReservation;
121
+ export type FlyBudgetSnapshot = RateBudgetSnapshot;