@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,1212 @@
1
+ // fly capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored top-down
2
+ // from Fly's first-party Machines API OpenAPI (live-fetched docs.machines.dev/swagger/doc.json,
3
+ // "Machines API 1.0" — 68 paths across the tags Apps / Machines / Volumes / Secrets /
4
+ // TLS Certificates / Tokens / Organizations / Platform / Postgres Clusters) cross-checked
5
+ // against superfly/fly-go (flyctl's own client) — see spec-sources.json — NOT from what this
6
+ // twin has built. Many entries start `todo` and coverage reads honestly partial.
7
+ //
8
+ // THE EXECUTION-PLANE DOCTRINE (the point of this pack): the control plane is twinned; the
9
+ // execution plane is REAL — a created/started machine actually runs config.image as a local
10
+ // Docker container through the injected `FlyContainerRuntime` seam. EVERY verify below runs
11
+ // OFFLINE: the seam is exercised with recording/failing FAKE runtimes (or the pure-ledger
12
+ // virtual default), never a live Docker daemon — the real-Docker proof lives in
13
+ // fly-docker.integration.test.ts, which self-skips loudly without a daemon and is NOT a
14
+ // capability proof. Real Fly runs Firecracker on Fly hardware; this twin runs containers —
15
+ // genuine compute, and the control-plane surface is identical.
16
+ //
17
+ // `verify()` (required to count as done) is ground truth and drives the KERNEL-BACKED handler
18
+ // on a FRESH temp root — never a spawned mock. Negative paths assert Fly's own status codes
19
+ // (docs response table: 401 / 404 / 408 / 422).
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
24
+ import { handleFlyTwinRequest } from "./fly-twin.js";
25
+ import { checkFlyConformance } from "./fly-conformance.js";
26
+ import { liveFlyExecute, syncFlyFromReal } from "./fly-connector.js";
27
+ import { FlyBudget, FlyBudgetError, FLY_BUDGET_CEILING, FLY_CALL_WEIGHTS } from "./fly-budget.js";
28
+ // (fly is an API-first vendor with no product UI to mirror — docs/contributing/architecture.md C1b: an integrator
29
+ // CALLS the Machines API / drives flyctl; fly.io's dashboard is incidental key/billing/log
30
+ // tooling, not where the work happens. No mirror, no UI capabilities — see README ## Coverage.)
31
+ const OCCURRED_AT = '2026-01-01T00:00:00.000Z';
32
+ /** A recording fake execution plane: every lifecycle call is recorded; overrides inject
33
+ * failures/exits so the seam's folds are provable offline. */
34
+ function fakeRuntime(overrides = {}) {
35
+ const calls = [];
36
+ let starts = 0;
37
+ const rec = (op) => (...args) => { calls.push({ op, args }); };
38
+ const runtime = {
39
+ kind: 'fake',
40
+ async run(spec) {
41
+ calls.push({ op: 'run', args: [spec] });
42
+ return { containerRef: `fake:${spec.machineId}`, publishedPorts: spec.ports.map((p, i) => ({ internal: p.internal, host: 41000 + i })) };
43
+ },
44
+ async stop(...args) { rec('stop')(...args); },
45
+ // start is a RECREATE, not a resume (Fly resets a stopped Machine's rootfs): the fake hands
46
+ // back a FRESH handle, exactly as a real plane that relaunched the image would.
47
+ async start(ref, spec) {
48
+ calls.push({ op: 'start', args: [ref, spec] });
49
+ starts += 1;
50
+ // A DIFFERENT host port than `run`'s: a recreated container gets fresh ephemeral ports, so
51
+ // a verify can tell "the new handle was re-ledgered" from "the old one was kept".
52
+ return { containerRef: `fake:${spec.machineId}:start${starts}`, publishedPorts: spec.ports.map((p, i) => ({ internal: p.internal, host: 42000 + i })) };
53
+ },
54
+ async remove(...args) { rec('remove')(...args); },
55
+ async pause(...args) { rec('pause')(...args); },
56
+ async unpause(...args) { rec('unpause')(...args); },
57
+ async signal(...args) { rec('signal')(...args); },
58
+ async exec(_ref, cmd) {
59
+ calls.push({ op: 'exec', args: [cmd] });
60
+ return { exitCode: 0, stdout: `ran:${cmd.join(' ')}`, stderr: '' };
61
+ },
62
+ async inspect() { return { running: true }; },
63
+ ...overrides,
64
+ };
65
+ return { runtime, calls };
66
+ }
67
+ async function withRoot(steps, opts = {}) {
68
+ const root = mkdtempSync(join(tmpdir(), 'fly-cap-'));
69
+ const h = (s) => handleFlyTwinRequest({
70
+ method: s.m,
71
+ path: s.p,
72
+ body: s.b === undefined ? undefined : JSON.stringify(s.b),
73
+ root,
74
+ occurredAt: s.at ?? OCCURRED_AT,
75
+ headers: { authorization: 'Bearer twin-test-token', ...(s.h ?? {}) },
76
+ ...(opts.runtime ? { runtime: opts.runtime } : {}),
77
+ });
78
+ try {
79
+ return await verifyBoundary('fly.withRoot', () => steps(h, root));
80
+ }
81
+ finally {
82
+ rmSync(root, { recursive: true, force: true });
83
+ }
84
+ }
85
+ /** Seed an app + a started machine (virtual runtime unless injected); returns the machine id. */
86
+ async function seedMachine(h, app = 'twin-app', config = { image: 'nginx:alpine' }, extra = {}) {
87
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: app, org_slug: 'personal' } });
88
+ const r = await h({ m: 'POST', p: `/v1/apps/${app}/machines`, b: { config, ...extra } });
89
+ return String(r.body.id ?? '');
90
+ }
91
+ const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
92
+ const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
93
+ export const FLY_CAPABILITIES = [
94
+ // ── THE HONEST CARVE-OUTS (what a local twin genuinely cannot be) ───────────────────────────
95
+ // ── AUTH (faked-but-enforced, D2) ─────────────────────────────────────────────────────────
96
+ done('fly.auth.bearer_required_401', 'auth', 'A missing/empty Authorization: Bearer token -> 401 (docs response table), never a served resource', 'api', 'core', () => withRoot(async (h) => {
97
+ const bare = await handleFlyTwinRequest({ method: 'GET', path: '/v1/apps?org_slug=personal', occurredAt: OCCURRED_AT });
98
+ const withTok = await h({ m: 'GET', p: '/v1/apps?org_slug=personal' });
99
+ return bare.status === 401 && typeof bare.body.error === 'string' && withTok.status === 200;
100
+ })),
101
+ done('fly.auth.read_only_rejects_writes', 'auth', 'readOnly twin rejects every write with 405 while reads still serve (D3) — including the runtime-exit fold, which must NOT persist state on a read-only GET', 'api', 'common', () => {
102
+ const { runtime } = fakeRuntime({ async inspect() { return { running: false, exitCode: 1 }; } });
103
+ return withRoot(async (h, root) => {
104
+ const id = await seedMachine(h, 'ro-app');
105
+ const write = await handleFlyTwinRequest({ method: 'POST', path: '/v1/apps', body: JSON.stringify({ app_name: 'ro-2', org_slug: 'personal' }), root, readOnly: true, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
106
+ const read = await handleFlyTwinRequest({ method: 'GET', path: '/v1/apps/ro-app', root, readOnly: true, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
107
+ // The fold is a WRITE: on a read-only twin a GET must serve control-plane state as-is and
108
+ // append nothing, even when the (injected) execution plane reports the container exited.
109
+ const roGet = await handleFlyTwinRequest({ method: 'GET', path: `/v1/apps/ro-app/machines/${id}`, root, readOnly: true, runtime, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
110
+ const rb = roGet.body;
111
+ return write.status === 405 && read.status === 200 && read.body.name === 'ro-app'
112
+ && rb.state === 'started' && rb.events.every((e) => e.type !== 'exit');
113
+ }, { runtime });
114
+ }),
115
+ // ── ERRORS (vendor envelope + unmodeled honesty) ──────────────────────────────────────────
116
+ done('fly.errors.envelope_shape', 'errors', 'Errors use Fly\'s ErrorResponse envelope: {error: string} with the HTTP status', 'api', 'core', () => withRoot(async (h) => {
117
+ const r = await h({ m: 'GET', p: '/v1/apps/no-such-app' });
118
+ return r.status === 404 && typeof r.body.error === 'string' && r.body.error.length > 0;
119
+ })),
120
+ done('fly.errors.unmodeled_fails_like_vendor', 'errors', 'Unmodeled-but-real routes (machine ps, org listings, certificates) fail 404 in the vendor envelope — never a fabricated success', 'api', 'core', () => withRoot(async (h) => {
121
+ const id = await seedMachine(h);
122
+ const ps = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/ps` });
123
+ const orgs = await h({ m: 'GET', p: '/v1/orgs/personal/machines' });
124
+ const certs = await h({ m: 'GET', p: '/v1/apps/twin-app/certificates' });
125
+ return [ps, orgs, certs].every((r) => r.status === 404 && typeof r.body.error === 'string');
126
+ })),
127
+ // ── APPS ──────────────────────────────────────────────────────────────────────────────────
128
+ done('fly.apps.create', 'apps', 'POST /v1/apps creates an app (201); GET shows name/org/status pending', 'api', 'core', () => withRoot(async (h) => {
129
+ const r = await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'my-app', org_slug: 'acme' } });
130
+ const got = await h({ m: 'GET', p: '/v1/apps/my-app' });
131
+ const b = got.body;
132
+ return r.status === 201 && got.status === 200 && b.name === 'my-app' && b.organization?.slug === 'acme' && b.status === 'pending' && b.machine_count === 0;
133
+ })),
134
+ done('fly.apps.create_name_taken_422', 'apps', 'Creating a taken app name -> 422 "Validation failed: Name has already been taken" with status name_taken (DOCUMENTED: docs response table + ErrorResponse.status enum)', 'api', 'core', () => withRoot(async (h) => {
135
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'dup-app', org_slug: 'acme' } });
136
+ const r = await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'dup-app', org_slug: 'acme' } });
137
+ const b = r.body;
138
+ return r.status === 422 && typeof b.error === 'string' && b.error.includes('already been taken') && b.status === 'name_taken';
139
+ })),
140
+ done('fly.apps.create_invalid_name_422', 'apps', 'An app name with uppercase/invalid characters -> 422 validation error', 'api', 'common', () => withRoot(async (h) => {
141
+ const r = await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'Bad_Name!', org_slug: 'acme' } });
142
+ return r.status === 422 && typeof r.body.error === 'string';
143
+ })),
144
+ done('fly.apps.list', 'apps', 'GET /v1/apps?org_slug= lists the org\'s apps as {total_apps, apps:[{id,name,machine_count,network,status}]}', 'api', 'core', () => withRoot(async (h) => {
145
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'list-a', org_slug: 'org-one' } });
146
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'list-b', org_slug: 'org-one' } });
147
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'other-org', org_slug: 'org-two' } });
148
+ const r = await h({ m: 'GET', p: '/v1/apps?org_slug=org-one' });
149
+ const b = r.body;
150
+ const names = b.apps.map((a) => a.name).sort();
151
+ return r.status === 200 && b.total_apps === 2 && names.join(',') === 'list-a,list-b';
152
+ })),
153
+ done('fly.apps.list_requires_org_slug', 'apps', 'GET /v1/apps without org_slug -> 400 (the OpenAPI marks the param required)', 'api', 'common', () => withRoot(async (h) => {
154
+ const r = await h({ m: 'GET', p: '/v1/apps' });
155
+ return r.status === 400 && typeof r.body.error === 'string';
156
+ })),
157
+ done('fly.apps.get_unknown_404', 'apps', 'GET /v1/apps/{unknown} -> 404 {error}', 'api', 'core', () => withRoot(async (h) => {
158
+ const r = await h({ m: 'GET', p: '/v1/apps/never-created' });
159
+ return r.status === 404 && typeof r.body.error === 'string';
160
+ })),
161
+ done('fly.apps.get_machine_count', 'apps', 'App show counts its live machines/volumes (machine_count/volume_count track creates and destroys; a pending_destroy volume counts until purged)', 'api', 'common', () => withRoot(async (h) => {
162
+ const id = await seedMachine(h, 'count-app');
163
+ await h({ m: 'POST', p: `/v1/apps/count-app/volumes`, b: { name: 'v_one', size_gb: 1 } });
164
+ const before = await h({ m: 'GET', p: '/v1/apps/count-app' });
165
+ await h({ m: 'POST', p: `/v1/apps/count-app/machines/${id}/stop` });
166
+ await h({ m: 'DELETE', p: `/v1/apps/count-app/machines/${id}` });
167
+ const vols = await h({ m: 'GET', p: '/v1/apps/count-app/volumes' });
168
+ await h({ m: 'DELETE', p: `/v1/apps/count-app/volumes/${vols.body[0].id}` });
169
+ // A deleted volume is pending_destroy and still counted until the purge window passes.
170
+ const after = await h({ m: 'GET', p: '/v1/apps/count-app', at: new Date(Date.parse(OCCURRED_AT) + 61_000).toISOString() });
171
+ return before.body.machine_count === 1 && before.body.volume_count === 1
172
+ && after.body.machine_count === 0 && after.body.volume_count === 0;
173
+ })),
174
+ done('fly.apps.delete_202', 'apps', 'DELETE /v1/apps/{name} -> 202 and the app 404s afterwards (its machines are destroyed with it)', 'api', 'core', () => withRoot(async (h) => {
175
+ const id = await seedMachine(h, 'doomed-app');
176
+ const del = await h({ m: 'DELETE', p: '/v1/apps/doomed-app' });
177
+ const got = await h({ m: 'GET', p: '/v1/apps/doomed-app' });
178
+ const machine = await h({ m: 'GET', p: `/v1/apps/doomed-app/machines/${id}` });
179
+ return del.status === 202 && got.status === 404 && machine.status === 404;
180
+ })),
181
+ done('fly.apps.recreate_after_delete', 'apps', 'Deleting an app destroys its VOLUMES and SECRETS with it (the execution plane is reaped too) and frees the name: the recreated app starts EMPTY on every projection — machines (even include_deleted), volumes, secrets, machine_count/volume_count', 'api', 'common', () => {
182
+ const removed = [];
183
+ const { runtime } = fakeRuntime({
184
+ async remove(ref) { removed.push(`c:${ref}`); },
185
+ async removeVolume(id) { removed.push(`v:${id}`); },
186
+ });
187
+ return withRoot(async (h) => {
188
+ const mid = await seedMachine(h, 'phoenix-app');
189
+ await h({ m: 'POST', p: '/v1/apps/phoenix-app/volumes', b: { name: 'ghost_vol', size_gb: 1 } });
190
+ await h({ m: 'POST', p: '/v1/apps/phoenix-app/secrets/GHOST_KEY', b: { value: 'first-life-credential' } });
191
+ await h({ m: 'DELETE', p: '/v1/apps/phoenix-app' });
192
+ const re = await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'phoenix-app', org_slug: 'personal' } });
193
+ const machines = await h({ m: 'GET', p: '/v1/apps/phoenix-app/machines' });
194
+ const withDeleted = await h({ m: 'GET', p: '/v1/apps/phoenix-app/machines?include_deleted=true' });
195
+ const volumes = await h({ m: 'GET', p: '/v1/apps/phoenix-app/volumes' });
196
+ const secrets = await h({ m: 'GET', p: '/v1/apps/phoenix-app/secrets' });
197
+ const ghost = await h({ m: 'GET', p: '/v1/apps/phoenix-app/secrets/GHOST_KEY?show_secrets=true' });
198
+ const app = await h({ m: 'GET', p: '/v1/apps/phoenix-app' });
199
+ const oldMachine = await h({ m: 'GET', p: `/v1/apps/phoenix-app/machines/${mid}` });
200
+ return re.status === 201 && machines.body.length === 0
201
+ && withDeleted.body.length === 0
202
+ && volumes.body.length === 0
203
+ && secrets.body.secrets.length === 0
204
+ && ghost.status === 404
205
+ && app.body.machine_count === 0 && app.body.volume_count === 0
206
+ && oldMachine.status === 404
207
+ && removed.some((r) => r.startsWith('c:fake:')) && removed.some((r) => r.startsWith('v:vol_'));
208
+ }, { runtime });
209
+ }),
210
+ todo('fly.apps.deploy_token', 'apps', 'POST /v1/apps/{name}/deploy_token mints a deploy token', 'api', 'niche'),
211
+ // ── MACHINES — CRUD + lifecycle ───────────────────────────────────────────────────────────
212
+ done('fly.machines.create', 'machines', 'POST machines with {config:{image}} creates AND starts a machine (Machine shape: 14-hex id, name, region, instance_id, config echoed, image_ref parsed)', 'api', 'core', () => withRoot(async (h) => {
213
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
214
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { name: 'web-1', region: 'lhr', config: { image: 'nginx:1.27', env: { A: 'b' } } } });
215
+ const b = r.body;
216
+ return r.status === 200 && /^[0-9a-f]{14}$/.test(b.id) && b.name === 'web-1' && b.region === 'lhr'
217
+ && b.state === 'started' && b.config?.image === 'nginx:1.27' && b.config?.env?.A === 'b'
218
+ && b.image_ref?.repository === 'library/nginx' && b.image_ref?.tag === '1.27'
219
+ && typeof b.instance_id === 'string' && b.instance_id.length === 26;
220
+ })),
221
+ done('fly.machines.create_skip_launch', 'machines', 'skip_launch:true leaves the machine in state created (no start event, nothing launched)', 'api', 'common', () => {
222
+ const { runtime, calls } = fakeRuntime();
223
+ return withRoot(async (h) => {
224
+ const id = await seedMachine(h, 'a1', { image: 'nginx:alpine' }, { skip_launch: true });
225
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}` });
226
+ const b = got.body;
227
+ const eventTypes = b.events.map((e) => e.type);
228
+ return b.state === 'created' && eventTypes.includes('launch') && !eventTypes.includes('start') && calls.every((c) => c.op !== 'run');
229
+ }, { runtime });
230
+ }),
231
+ done('fly.machines.create_requires_image_422', 'machines', 'A config without an image -> 422 {error}; no machine is created', 'api', 'core', () => withRoot(async (h) => {
232
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
233
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { env: { A: 'b' } } } });
234
+ const list = await h({ m: 'GET', p: '/v1/apps/a1/machines' });
235
+ return r.status === 422 && typeof r.body.error === 'string' && list.body.length === 0;
236
+ })),
237
+ done('fly.machines.create_unknown_app_404', 'machines', 'Creating a machine under an app that does not exist -> 404', 'api', 'core', () => withRoot(async (h) => {
238
+ const r = await h({ m: 'POST', p: '/v1/apps/ghost/machines', b: { config: { image: 'nginx' } } });
239
+ return r.status === 404 && typeof r.body.error === 'string';
240
+ })),
241
+ done('fly.machines.create_name_conflict_422', 'machines', 'A duplicate machine name within the app -> 422 already_exists (⚠ exact message doc-unverified; the uniqueness fact is fly-documented)', 'api', 'common', () => withRoot(async (h) => {
242
+ await seedMachine(h, 'a1', { image: 'nginx' }, { name: 'web' });
243
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { name: 'web', config: { image: 'nginx' } } });
244
+ return r.status === 422 && String(r.body.error).includes('already');
245
+ })),
246
+ done('fly.machines.generated_name_and_default_region', 'machines', 'Omitting name/region generates a Fly-style adjective-noun-NNNN name and defaults the region', 'api', 'common', () => withRoot(async (h) => {
247
+ const id = await seedMachine(h);
248
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
249
+ const b = got.body;
250
+ return /^[a-z]+-[a-z]+-\d{4}$/.test(b.name) && typeof b.region === 'string' && b.region.length > 0;
251
+ })),
252
+ done('fly.machines.get', 'machines', 'GET machines/{id} returns the full Machine (config, image_ref, events, private_ip, version=instance_id, host_status ok)', 'api', 'core', () => withRoot(async (h) => {
253
+ const id = await seedMachine(h);
254
+ const r = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
255
+ const b = r.body;
256
+ return r.status === 200 && b.id === id && Array.isArray(b.events) && b.events.length >= 2
257
+ && b.version === b.instance_id && b.host_status === 'ok' && b.cordoned === false
258
+ && String(b.private_ip).startsWith('fdaa:');
259
+ })),
260
+ done('fly.machines.get_unknown_404', 'machines', 'GET machines/{unknown} -> 404 "machine not found"-style {error}', 'api', 'core', () => withRoot(async (h) => {
261
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
262
+ const r = await h({ m: 'GET', p: '/v1/apps/a1/machines/00000000000000' });
263
+ return r.status === 404 && typeof r.body.error === 'string';
264
+ })),
265
+ done('fly.machines.list', 'machines', 'GET machines lists live machines; ?state= and ?region= filter; destroyed machines appear only with include_deleted=true', 'api', 'core', () => withRoot(async (h) => {
266
+ const a = await seedMachine(h, 'a1', { image: 'nginx' }, { name: 'm-a', region: 'lhr' });
267
+ await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { name: 'm-b', region: 'iad', config: { image: 'nginx' } } });
268
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${a}/stop` });
269
+ await h({ m: 'DELETE', p: `/v1/apps/a1/machines/${a}` });
270
+ const live = await h({ m: 'GET', p: '/v1/apps/a1/machines' });
271
+ const withDeleted = await h({ m: 'GET', p: '/v1/apps/a1/machines?include_deleted=true' });
272
+ const lhr = await h({ m: 'GET', p: '/v1/apps/a1/machines?region=lhr&include_deleted=true' });
273
+ const started = await h({ m: 'GET', p: '/v1/apps/a1/machines?state=started' });
274
+ // ?state= is documented as a COMMA-SEPARATED list.
275
+ const multi = await h({ m: 'GET', p: '/v1/apps/a1/machines?state=started,destroyed&include_deleted=true' });
276
+ return live.body.length === 1 && withDeleted.body.length === 2
277
+ && lhr.body.every((m) => m.region === 'lhr') && lhr.body.length === 1
278
+ && started.body.length === 1 && started.body[0].name === 'm-b'
279
+ && multi.body.length === 2;
280
+ })),
281
+ done('fly.machines.update_replaces_config', 'machines', 'POST machines/{id} replaces config, mints a NEW instance_id/version and appends an update event (the old version stays in /versions)', 'api', 'core', () => withRoot(async (h) => {
282
+ const id = await seedMachine(h);
283
+ const before = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
284
+ const up = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}`, b: { config: { image: 'redis:7', env: { NEW: 'yes' } } } });
285
+ const b = up.body;
286
+ const versions = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/versions` });
287
+ return up.status === 200 && b.config?.image === 'redis:7' && b.instance_id !== before.body.instance_id
288
+ && versions.body.length === 2
289
+ && b.events.some((e) => e.type === 'update');
290
+ })),
291
+ done('fly.machines.update_requires_image_422', 'machines', 'An update whose config drops the image -> 422; the machine keeps its old config', 'api', 'common', () => withRoot(async (h) => {
292
+ const id = await seedMachine(h);
293
+ const r = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}`, b: { config: { env: { A: 'b' } } } });
294
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
295
+ return r.status === 422 && got.body.config?.image === 'nginx:alpine';
296
+ })),
297
+ done('fly.machines.delete_stopped', 'machines', 'DELETE a stopped machine destroys it: destroy event, state destroyed (readable via include_deleted), gone from the live list', 'api', 'core', () => withRoot(async (h) => {
298
+ const id = await seedMachine(h);
299
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
300
+ const del = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}` });
301
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
302
+ const b = got.body;
303
+ return del.status === 200 && b.state === 'destroyed' && b.events.some((e) => e.type === 'destroy');
304
+ })),
305
+ done('fly.machines.delete_active_needs_force', 'machines', 'DELETE a STARTED machine -> 400 without force; ?force=true kills and destroys it (⚠ exact 400 message doc-unverified; the force semantics are documented)', 'api', 'core', () => withRoot(async (h) => {
306
+ const id = await seedMachine(h);
307
+ const refused = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}` });
308
+ const forced = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}?force=true` });
309
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
310
+ return refused.status === 400 && typeof refused.body.error === 'string'
311
+ && forced.status === 200 && got.body.state === 'destroyed';
312
+ })),
313
+ // ── STOP AND START ARE TWO CAPABILITIES, not one lifecycle ────────────────────────────────
314
+ // These verbs read as ONE behaviour and were once ONE manifest entry
315
+ // (`fly.machines.start_stop_lifecycle`). They are not one thing to whatever SCOPES access to
316
+ // this vendor: a scoping consumer names capability ids out of this manifest (that is what
317
+ // `fly-spec-census.json` publishes them for), and one id covering both verbs makes "may stop,
318
+ // may not start" — and its reverse, which is the interesting one, since starting a machine is
319
+ // how a stopped service comes back — inexpressible. The split therefore belongs HERE, in the
320
+ // twin that owns the vocabulary, and not in the consumer that speaks it. Same routes, same
321
+ // twin behaviour, two verifies: each verb's own semantics, each verb's own idempotence.
322
+ done('fly.machines.stop', 'machines', 'POST machines/{id}/stop -> stopped, with a stop event; idempotent when the machine is already stopped', 'api', 'core', () => withRoot(async (h) => {
323
+ const id = await seedMachine(h);
324
+ const stop = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
325
+ const stopped = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
326
+ const stopAgain = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
327
+ return stop.status === 200 && stopped.body.state === 'stopped'
328
+ && stopped.body.events.some((e) => e.type === 'stop')
329
+ && stopAgain.status === 200;
330
+ })),
331
+ done('fly.machines.start', 'machines', 'POST machines/{id}/start on a stopped machine -> started with {previous_state:"stopped"} and a start event; idempotent when already started', 'api', 'core', () => withRoot(async (h) => {
332
+ const id = await seedMachine(h);
333
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
334
+ const start = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/start`, at: '2026-01-01T00:00:01.000Z' });
335
+ const started = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
336
+ const startAgain = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/start` });
337
+ return start.status === 200 && start.body.previous_state === 'stopped'
338
+ && started.body.state === 'started'
339
+ && started.body.events.some((e) => e.type === 'start')
340
+ && startAgain.status === 200 && startAgain.body.previous_state === 'started';
341
+ })),
342
+ done('fly.machines.restart', 'machines', 'POST restart on a started machine restarts it (restart + start events, still started)', 'api', 'core', () => withRoot(async (h) => {
343
+ const id = await seedMachine(h);
344
+ const r = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/restart` });
345
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
346
+ const b = got.body;
347
+ return r.status === 200 && b.state === 'started' && b.events.some((e) => e.type === 'restart');
348
+ })),
349
+ done('fly.machines.signal', 'machines', 'POST signal delivers a valid signal to a started machine; an invalid signal -> 400; a stopped machine -> 400', 'api', 'common', () => withRoot(async (h) => {
350
+ const id = await seedMachine(h);
351
+ const ok = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/signal`, b: { signal: 'SIGHUP' } });
352
+ const bad = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/signal`, b: { signal: 'SIGNOPE' } });
353
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
354
+ const stopped = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/signal`, b: { signal: 'SIGHUP' } });
355
+ return ok.status === 200 && bad.status === 400 && stopped.status === 400;
356
+ })),
357
+ done('fly.machines.suspend_resume', 'machines', 'POST suspend -> suspended (suspend event); start resumes -> started with previous_state suspended; suspending a stopped machine -> 400', 'api', 'common', () => withRoot(async (h) => {
358
+ const id = await seedMachine(h);
359
+ const sus = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/suspend` });
360
+ const suspended = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
361
+ const resume = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/start`, at: '2026-01-01T00:00:01.000Z' });
362
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
363
+ const badSus = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/suspend` });
364
+ return sus.status === 200 && suspended.body.state === 'suspended'
365
+ && resume.status === 200 && resume.body.previous_state === 'suspended'
366
+ && badSus.status === 400;
367
+ })),
368
+ done('fly.machines.cordon_uncordon', 'machines', 'cordon/uncordon flip the machine\'s cordoned flag', 'api', 'niche', () => withRoot(async (h) => {
369
+ const id = await seedMachine(h);
370
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/cordon` });
371
+ const on = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
372
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/uncordon` });
373
+ const off = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
374
+ return on.body.cordoned === true && off.body.cordoned === false;
375
+ })),
376
+ done('fly.machines.wait', 'machines', 'GET wait?state= answers {ok:true} when the machine is in the desired state; a state it is not in -> 408 with Fly\'s real timeout message; an invalid state -> 400', 'api', 'core', () => withRoot(async (h) => {
377
+ const id = await seedMachine(h);
378
+ const ok = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/wait?state=started` });
379
+ const timeout = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/wait?state=stopped&timeout=1` });
380
+ const invalid = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/wait?state=warp-speed` });
381
+ return ok.status === 200 && ok.body.ok === true
382
+ && timeout.status === 408 && String(timeout.body.error).includes('timeout reached waiting for machine')
383
+ && invalid.status === 400;
384
+ })),
385
+ done('fly.machines.wait_destroyed', 'machines', 'wait?state=destroyed resolves ok once a machine is destroyed (the reap-rehearsal wait flyctl uses)', 'api', 'common', () => withRoot(async (h) => {
386
+ const id = await seedMachine(h);
387
+ const before = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/wait?state=destroyed` });
388
+ await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}?force=true` });
389
+ const after = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/wait?state=destroyed` });
390
+ return before.status === 408 && after.status === 200 && after.body.ok === true;
391
+ })),
392
+ done('fly.machines.events_ledger', 'machines', 'GET events returns the machine\'s ledgered MachineEvent stream (type/status/source/timestamp), newest first, ?limit= trims', 'api', 'core', () => withRoot(async (h) => {
393
+ const id = await seedMachine(h);
394
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop`, at: '2026-01-01T00:00:05.000Z' });
395
+ const r = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/events` });
396
+ const events = r.body;
397
+ const limited = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/events?limit=1` });
398
+ return r.status === 200 && events.length === 3
399
+ && events[0].type === 'stop' && events[events.length - 1].type === 'launch'
400
+ && events.every((e) => typeof e.timestamp === 'number' && (e.source === 'user' || e.source === 'flyd') && typeof e.status === 'string')
401
+ && limited.body.length === 1 && limited.body[0].type === 'stop';
402
+ })),
403
+ done('fly.machines.metadata', 'machines', 'Machine metadata: GET map, GET single {value}, POST upsert (204), DELETE (204), unknown key -> 404', 'api', 'common', () => withRoot(async (h) => {
404
+ const id = await seedMachine(h, 'twin-app', { image: 'nginx', metadata: { fly_platform_version: 'v2' } });
405
+ const map = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/metadata` });
406
+ const set = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/metadata/role`, b: { value: 'worker' } });
407
+ const single = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/metadata/role` });
408
+ const del = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}/metadata/role` });
409
+ const gone = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/metadata/role` });
410
+ return map.body.fly_platform_version === 'v2' && set.status === 204
411
+ && single.status === 200 && single.body.value === 'worker'
412
+ && del.status === 204 && gone.status === 404;
413
+ })),
414
+ done('fly.machines.recreate_name_after_destroy', 'machines', 'Destroying a machine frees its name for a new create (dirty-state: uniqueness is over LIVE machines only)', 'api', 'common', () => withRoot(async (h) => {
415
+ const id = await seedMachine(h, 'a1', { image: 'nginx' }, { name: 'reborn' });
416
+ await h({ m: 'DELETE', p: `/v1/apps/a1/machines/${id}?force=true` });
417
+ const re = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { name: 'reborn', config: { image: 'nginx' } } });
418
+ const b = re.body;
419
+ return re.status === 200 && b.name === 'reborn' && b.id !== id && b.state === 'started';
420
+ })),
421
+ todo('fly.machines.ps', 'machines', 'GET machines/{id}/ps lists guest processes (ProcessStat)', 'api', 'niche'),
422
+ todo('fly.machines.memory_endpoints', 'machines', 'GET/PUT machines/{id}/memory + POST memory/reclaim', 'api', 'niche'),
423
+ todo('fly.machines.metadata_bulk_put_412', 'machines', 'PUT/PATCH machines/{id}/metadata full-replace with 412 conflict semantics (deploys write fly_process_group/fly_release_id in bulk)', 'api', 'common'),
424
+ todo('fly.machines.list_summary', 'machines', 'machines list ?summary=true trims config from the rows (what flyctl uses on large apps)', 'api', 'common'),
425
+ todo('fly.machines.create_skip_flags', 'machines', 'CreateMachineRequest.skip_secrets / skip_service_registration / min_secrets_version', 'api', 'niche'),
426
+ todo('fly.apps.list_app_role_filter', 'apps', 'GET /v1/apps ?app_role= filter', 'api', 'niche'),
427
+ todo('fly.machines.auto_destroy', 'machines', 'config.auto_destroy destroys the machine when its process exits cleanly', 'api', 'niche'),
428
+ todo('fly.machines.stop_timeout_signal_body', 'machines', 'stop body {signal,timeout} honors Go duration strings ("30s") in addition to numbers', 'api', 'niche'),
429
+ todo('fly.machines.standbys', 'machines', 'config.standbys: a standby machine starts when its watched machine fails', 'api', 'niche'),
430
+ todo('fly.machines.schedule', 'machines', 'config.schedule (hourly/daily/…) scheduled starts', 'api', 'niche'),
431
+ // ── LEASES ────────────────────────────────────────────────────────────────────────────────
432
+ done('fly.leases.acquire_wrapped', 'leases', 'POST lease -> 201 {status:"success", data:{nonce,expires_at,owner,version}} (the fly-go MachineLease WRAP, which the OpenAPI omits)', 'api', 'core', () => withRoot(async (h) => {
433
+ const id = await seedMachine(h);
434
+ const r = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
435
+ const b = r.body;
436
+ return r.status === 201 && b.status === 'success' && typeof b.data?.nonce === 'string' && b.data.nonce.length > 0
437
+ && b.data.expires_at === Math.floor(Date.parse(OCCURRED_AT) / 1000) + 300 && typeof b.data.owner === 'string';
438
+ })),
439
+ done('fly.leases.get', 'leases', 'GET lease shows the live lease (wrapped); with none held -> 404', 'api', 'common', () => withRoot(async (h) => {
440
+ const id = await seedMachine(h);
441
+ const none = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/lease` });
442
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
443
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/lease` });
444
+ return none.status === 404 && got.status === 200 && got.body.data?.expires_at > 0;
445
+ })),
446
+ done('fly.leases.mutation_guard', 'leases', 'A live lease BLOCKS state-mutating machine ops without the fly-machine-lease-nonce header (400 naming the holder) and admits them with it', 'api', 'core', () => withRoot(async (h) => {
447
+ const id = await seedMachine(h);
448
+ const lease = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
449
+ const nonce = lease.body.data.nonce;
450
+ const blocked = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
451
+ const wrong = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop`, h: { 'fly-machine-lease-nonce': 'not-the-nonce' } });
452
+ const metaBlocked = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/metadata/role`, b: { value: 'x' } });
453
+ const allowed = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop`, h: { 'fly-machine-lease-nonce': nonce } });
454
+ const state = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
455
+ return blocked.status === 400 && String(blocked.body.error).includes('lease currently held')
456
+ && wrong.status === 400 && metaBlocked.status === 400
457
+ && allowed.status === 200 && state.body.state === 'stopped';
458
+ })),
459
+ done('fly.leases.release', 'leases', 'DELETE lease with the nonce releases it (mutations flow again); a WRONG nonce -> 400 and the lease survives', 'api', 'core', () => withRoot(async (h) => {
460
+ const id = await seedMachine(h);
461
+ const lease = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
462
+ const nonce = lease.body.data.nonce;
463
+ const wrong = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}/lease`, h: { 'fly-machine-lease-nonce': 'bogus' } });
464
+ const stillHeld = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/lease` });
465
+ const released = await h({ m: 'DELETE', p: `/v1/apps/twin-app/machines/${id}/lease`, h: { 'fly-machine-lease-nonce': nonce } });
466
+ const freed = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
467
+ return wrong.status === 400 && stillHeld.status === 200 && released.status === 200 && freed.status === 200;
468
+ })),
469
+ done('fly.leases.refresh_keeps_nonce', 'leases', 'POST lease WITH the current nonce refreshes the ttl and keeps the same nonce (fly-go RefreshLease)', 'api', 'common', () => withRoot(async (h) => {
470
+ const id = await seedMachine(h);
471
+ const first = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 60 } });
472
+ const nonce = first.body.data.nonce;
473
+ const refreshed = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 600 }, h: { 'fly-machine-lease-nonce': nonce } });
474
+ const b = refreshed.body;
475
+ return refreshed.status === 201 && b.data.nonce === nonce && b.data.expires_at === Math.floor(Date.parse(OCCURRED_AT) / 1000) + 600;
476
+ })),
477
+ done('fly.leases.conflict_409', 'leases', 'Acquiring a lease already held by someone else (no/wrong nonce) -> 409 (⚠ status doc-unverified; the conflict fact is the point)', 'api', 'common', () => withRoot(async (h) => {
478
+ const id = await seedMachine(h);
479
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
480
+ const r = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
481
+ return r.status === 409 && typeof r.body.error === 'string';
482
+ })),
483
+ done('fly.leases.expiry', 'leases', 'An expired lease no longer guards or serves ANYWHERE: the lease read 404s, the machine view (even with include_leases=true) drops it, and mutations flow (clock-driven via occurredAt, deterministic)', 'api', 'common', () => withRoot(async (h) => {
484
+ const id = await seedMachine(h);
485
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 60 } });
486
+ const later = '2026-01-01T00:02:00.000Z'; // ttl 60s, read 120s on
487
+ const expired = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}/lease`, at: later });
488
+ const view = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}?include_leases=true`, at: later });
489
+ const mutate = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop`, at: later });
490
+ return expired.status === 404 && view.body.lease === undefined && mutate.status === 200;
491
+ })),
492
+ done('fly.leases.include_leases_param', 'leases', 'Machine.lease is served only behind ?include_leases=true (machines get + list declare the param) and only while the lease is LIVE', 'api', 'common', () => withRoot(async (h) => {
493
+ const id = await seedMachine(h);
494
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/lease`, b: { ttl: 300 } });
495
+ const bare = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
496
+ const asked = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}?include_leases=true` });
497
+ const list = await h({ m: 'GET', p: '/v1/apps/twin-app/machines?include_leases=true' });
498
+ const lb = asked.body.lease;
499
+ return bare.body.lease === undefined
500
+ && lb !== undefined && typeof lb.owner === 'string' && typeof lb.expires_at === 'number'
501
+ && list.body[0].lease !== undefined;
502
+ })),
503
+ done('fly.leases.nonce_on_create', 'leases', 'CreateMachineRequest.lease_ttl mints a lease at create: the Machine reply carries `nonce` (spec: "only ever returned on machine creation")', 'api', 'niche', () => withRoot(async (h) => {
504
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
505
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'nginx' }, lease_ttl: 120 } });
506
+ const b = r.body;
507
+ const blocked = await h({ m: 'POST', p: `/v1/apps/a1/machines/${b.id}/stop` });
508
+ return typeof b.nonce === 'string' && b.nonce.length > 0 && blocked.status === 400;
509
+ })),
510
+ // ── EXECUTION (the real-plane seam: control plane -> container runtime) ───────────────────
511
+ done('fly.execution.container_launch_on_create', 'execution', 'Machine create drives the execution plane: runtime.run() receives the machine\'s image and guest CPU/memory limits, and the container ref is ledgered (twin_runtime tags the plane)', 'api', 'core', () => {
512
+ const { runtime, calls } = fakeRuntime();
513
+ return withRoot(async (h) => {
514
+ const id = await seedMachine(h, 'a1', { image: 'ghcr.io/acme/worker:v3', guest: { memory_mb: 768, cpus: 2 } });
515
+ const runs = calls.filter((c) => c.op === 'run');
516
+ const spec = runs[0]?.args[0];
517
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}` });
518
+ return runs.length === 1 && spec?.image === 'ghcr.io/acme/worker:v3' && spec.machineId === id
519
+ && spec.memoryMiB === 768 && spec.cpus === 2
520
+ && got.body.twin_runtime === 'fake';
521
+ }, { runtime });
522
+ }),
523
+ done('fly.execution.env_passthrough', 'execution', 'config.env reaches the container spec verbatim, layered OVER Fly\'s documented runtime env (user value wins a collision)', 'api', 'core', () => {
524
+ const { runtime, calls } = fakeRuntime();
525
+ return withRoot(async (h) => {
526
+ await seedMachine(h, 'a1', { image: 'nginx', env: { DATABASE_URL: 'postgres://x', FLY_PROCESS_GROUP: 'worker' } });
527
+ const spec = calls.find((c) => c.op === 'run')?.args[0];
528
+ return spec.env.DATABASE_URL === 'postgres://x' && spec.env.FLY_PROCESS_GROUP === 'worker';
529
+ }, { runtime });
530
+ }),
531
+ done('fly.execution.fly_runtime_env', 'execution', 'The container boots with Fly\'s documented machine runtime environment: FLY_APP_NAME, FLY_MACHINE_ID, FLY_REGION, FLY_PRIVATE_IP, FLY_MACHINE_VERSION, FLY_VM_MEMORY_MB (fly.io/docs/machines/runtime-environment)', 'api', 'core', () => {
532
+ const { runtime, calls } = fakeRuntime();
533
+ return withRoot(async (h) => {
534
+ const id = await seedMachine(h, 'a1', { image: 'nginx', guest: { memory_mb: 512 } });
535
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}` });
536
+ const b = got.body;
537
+ const spec = calls.find((c) => c.op === 'run')?.args[0];
538
+ return spec.env.FLY_APP_NAME === 'a1' && spec.env.FLY_MACHINE_ID === id
539
+ && spec.env.FLY_REGION === b.region && spec.env.FLY_PRIVATE_IP === b.private_ip
540
+ && spec.env.FLY_MACHINE_VERSION === b.instance_id && spec.env.FLY_VM_MEMORY_MB === '512';
541
+ }, { runtime });
542
+ }),
543
+ done('fly.execution.ports_published', 'execution', 'Every service internal_port lands in the container spec and the runtime\'s published loopback ports are ledgered as twin_local_ports (disclosed additive field)', 'api', 'core', () => {
544
+ const { runtime } = fakeRuntime();
545
+ return withRoot(async (h) => {
546
+ const id = await seedMachine(h, 'a1', {
547
+ image: 'nginx',
548
+ services: [
549
+ { internal_port: 8080, protocol: 'tcp', ports: [{ port: 443, handlers: ['tls', 'http'] }] },
550
+ { internal_port: 9000, protocol: 'tcp', ports: [{ port: 9000 }] },
551
+ ],
552
+ });
553
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}` });
554
+ const ports = got.body.twin_local_ports;
555
+ return ports.length === 2 && ports[0].internal === 8080 && ports[0].host === 41000 && ports[1].internal === 9000;
556
+ }, { runtime });
557
+ }),
558
+ done('fly.execution.mounts_to_volumes', 'execution', 'config.mounts resolve to the app\'s volumes: the container spec mounts the volume id at the path, and the volume shows attached_machine_id', 'api', 'core', () => {
559
+ const { runtime, calls } = fakeRuntime();
560
+ return withRoot(async (h) => {
561
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
562
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
563
+ const volId = vol.body.id;
564
+ const mr = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/data' }] } } });
565
+ const spec = calls.find((c) => c.op === 'run')?.args[0];
566
+ const volAfter = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}` });
567
+ return spec.mounts.length === 1 && spec.mounts[0].volumeId === volId && spec.mounts[0].path === '/data'
568
+ && volAfter.body.attached_machine_id === mr.body.id;
569
+ }, { runtime });
570
+ }),
571
+ done('fly.execution.stop_reaches_runtime', 'execution', 'POST stop drives runtime.stop() with the StopRequest\'s signal/timeout; destroy (force) drives runtime.remove()', 'api', 'core', () => {
572
+ const { runtime, calls } = fakeRuntime();
573
+ return withRoot(async (h) => {
574
+ const id = await seedMachine(h, 'a1');
575
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/stop`, b: { signal: 'SIGINT', timeout: 15 } });
576
+ const stop = calls.find((c) => c.op === 'stop');
577
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/start`, at: '2026-01-01T00:00:01.000Z' });
578
+ await h({ m: 'DELETE', p: `/v1/apps/a1/machines/${id}?force=true` });
579
+ const removed = calls.some((c) => c.op === 'remove');
580
+ return stop !== undefined && stop.args[1].signal === 'SIGINT' && stop.args[1].timeoutSeconds === 15 && removed;
581
+ }, { runtime });
582
+ }),
583
+ done('fly.execution.rootfs_reset_on_start', 'execution', 'stop\u2192start RESETS the machine\'s root filesystem like the vendor ("Stopped Machines that are restarted are completely reset to their original state"): the plane is asked to RECREATE the container from the machine\'s spec \u2014 runtime.start() receives the full container spec (image, env, ports, mounts), never a bare resume \u2014 and the new handle\'s published ports are re-ledgered, while config.mounts stay mounted so volume data survives', 'api', 'core', () => {
584
+ const { runtime, calls } = fakeRuntime();
585
+ return withRoot(async (h) => {
586
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
587
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
588
+ const volId = vol.body.id;
589
+ const cr = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: {
590
+ image: 'app:v1',
591
+ mounts: [{ volume: volId, path: '/data' }],
592
+ services: [{ internal_port: 8080, protocol: 'tcp', ports: [{ port: 80 }] }],
593
+ } } });
594
+ const id = cr.body.id;
595
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/stop` });
596
+ const started = await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/start`, at: '2026-01-01T00:00:01.000Z' });
597
+ const after = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}`, at: '2026-01-01T00:00:02.000Z' });
598
+ const start = calls.find((c) => c.op === 'start');
599
+ const spec = start?.args[1];
600
+ const ports = after.body.twin_local_ports;
601
+ return started.status === 200 && after.body.state === 'started'
602
+ // The plane was handed the whole spec — the image is what a reset restarts FROM.
603
+ && spec?.image === 'app:v1' && spec.machineId === id
604
+ // …with the mount wiring intact, so volume-backed data is what survives the reset.
605
+ && spec.mounts.length === 1 && spec.mounts[0].volumeId === volId && spec.mounts[0].path === '/data'
606
+ && spec.ports.length === 1 && spec.ports[0].internal === 8080
607
+ // The recreated container's NEW published port is what the machine now advertises.
608
+ && ports.length === 1 && ports[0].host === 42000
609
+ // The plane owns the recreate: the handler does not double-launch behind its back.
610
+ && calls.filter((c) => c.op === 'run').length === 1;
611
+ }, { runtime });
612
+ }),
613
+ done('fly.execution.exit_fold', 'execution', 'Under restart policy `no`, a container that EXITED folds the machine to stopped on the next read, with exit code and OOM cause ledgered into the event\'s fly-go-shaped request payload — and no relaunch is attempted', 'api', 'core', () => {
614
+ let exited = false;
615
+ const { runtime, calls } = fakeRuntime({ async inspect() { return exited ? { running: false, exitCode: 137, oomKilled: true } : { running: true }; } });
616
+ return withRoot(async (h) => {
617
+ const id = await seedMachine(h, 'a1', { image: 'nginx:alpine', restart: { policy: 'no' } });
618
+ const before = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}` });
619
+ exited = true;
620
+ const after = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
621
+ const b = after.body;
622
+ const exit = b.events.find((e) => e.type === 'exit');
623
+ return before.body.state === 'started' && b.state === 'stopped'
624
+ && exit !== undefined && exit.source === 'flyd'
625
+ && exit.request?.exit_event?.exit_code === 137
626
+ && exit.request?.exit_event?.oom_killed === true
627
+ && calls.filter((c) => c.op === 'run').length === 1; // policy no: never relaunched
628
+ }, { runtime });
629
+ }),
630
+ done('fly.execution.restart_failure_diagnostic', 'execution', 'If automatic restart is refused, the machine stops with the sanitized causal failure retained on the machine and its final exit event instead of silently discarding the catch', 'api', 'core', () => {
631
+ let exited = false;
632
+ let runs = 0;
633
+ const { runtime } = fakeRuntime({
634
+ async inspect() { return exited ? { running: false, exitCode: 137, oomKilled: true } : { running: true }; },
635
+ async run(spec) {
636
+ runs += 1;
637
+ if (runs > 1)
638
+ throw new Error(`World "demo": service "machine-provider": machine "${spec.machineId}": resource admission refused`);
639
+ return { containerRef: `fake:${spec.machineId}` };
640
+ },
641
+ });
642
+ return withRoot(async (h) => {
643
+ const id = await seedMachine(h, 'diag');
644
+ exited = true;
645
+ const got = await h({ m: 'GET', p: `/v1/apps/diag/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
646
+ const body = got.body;
647
+ const finalExit = body.events.find((event) => event.type === 'exit' && event.status === 'stopped');
648
+ return body.state === 'stopped'
649
+ && String(body.twin_last_failure).includes('resource admission refused')
650
+ && String(finalExit?.request?.restart_error).includes('resource admission refused');
651
+ }, { runtime });
652
+ }),
653
+ done('fly.machines.restart_policy', 'machines', 'config.restart drives what an EXIT does (fly.MachineRestart): the DEFAULT (no policy set) is on-failure — a non-zero exit relaunches the container (bounded by max_retries), a CLEAN exit stops; policy `always` relaunches even a clean exit', 'api', 'core', () => {
654
+ // Leg 1: default policy + exit 1 → relaunch (run called twice, still started, flyd start event).
655
+ const l1 = (() => {
656
+ let exited = false;
657
+ const { runtime, calls } = fakeRuntime({ async inspect() { return exited ? { running: false, exitCode: 1 } : { running: true }; } });
658
+ return withRoot(async (h) => {
659
+ const id = await seedMachine(h, 'a1');
660
+ exited = true;
661
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
662
+ const b = got.body;
663
+ // The relaunch must REAP the exited container first (deterministic docker names — a
664
+ // run-into-conflict would make on-failure inert on the real plane) and must ledger the
665
+ // exit CODE into the restarting event's fly-go request payload.
666
+ return b.state === 'started' && calls.filter((c) => c.op === 'run').length === 2
667
+ && calls.some((c) => c.op === 'remove')
668
+ && b.events.some((e) => e.type === 'exit' && e.status === 'restarting' && e.request?.exit_event?.exit_code === 1);
669
+ }, { runtime });
670
+ })();
671
+ // Leg 2: default policy + CLEAN exit → stopped, no relaunch.
672
+ const l2 = (() => {
673
+ let exited = false;
674
+ const { runtime, calls } = fakeRuntime({ async inspect() { return exited ? { running: false, exitCode: 0 } : { running: true }; } });
675
+ return withRoot(async (h) => {
676
+ const id = await seedMachine(h, 'a2');
677
+ exited = true;
678
+ const got = await h({ m: 'GET', p: `/v1/apps/a2/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
679
+ return got.body.state === 'stopped' && calls.filter((c) => c.op === 'run').length === 1;
680
+ }, { runtime });
681
+ })();
682
+ // Leg 3: on-failure with max_retries 1 — the SECOND exit is not retried (dirty-state bound),
683
+ // and an explicit operator RESTART restores the supervision budget (the reset is load-bearing:
684
+ // without it a once-exhausted machine gets zero flyd supervision forever).
685
+ const l3 = (() => {
686
+ const { runtime, calls } = fakeRuntime({ async inspect() { return { running: false, exitCode: 1 }; } });
687
+ return withRoot(async (h) => {
688
+ const id = await seedMachine(h, 'a3', { image: 'nginx:alpine', restart: { policy: 'on-failure', max_retries: 1 } });
689
+ const first = await h({ m: 'GET', p: `/v1/apps/a3/machines/${id}`, at: '2026-01-01T00:00:05.000Z' });
690
+ const second = await h({ m: 'GET', p: `/v1/apps/a3/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
691
+ const runsBeforeRestart = calls.filter((c) => c.op === 'run').length;
692
+ await h({ m: 'POST', p: `/v1/apps/a3/machines/${id}/restart`, at: '2026-01-01T00:00:12.000Z' });
693
+ const afterRestart = await h({ m: 'GET', p: `/v1/apps/a3/machines/${id}`, at: '2026-01-01T00:00:15.000Z' });
694
+ return first.body.state === 'started' && second.body.state === 'stopped'
695
+ && runsBeforeRestart === 2 // create + exactly ONE retry
696
+ && afterRestart.body.state === 'started' // budget restored: flyd retried again
697
+ && calls.filter((c) => c.op === 'run').length === 4; // + restart's run + the restored retry
698
+ }, { runtime });
699
+ })();
700
+ // Leg 4: policy always + clean exit → relaunched.
701
+ const l4 = (() => {
702
+ let exited = false;
703
+ const { runtime, calls } = fakeRuntime({ async inspect() { return exited ? { running: false, exitCode: 0 } : { running: true }; } });
704
+ return withRoot(async (h) => {
705
+ const id = await seedMachine(h, 'a4', { image: 'nginx:alpine', restart: { policy: 'always' } });
706
+ exited = true;
707
+ const got = await h({ m: 'GET', p: `/v1/apps/a4/machines/${id}`, at: '2026-01-01T00:00:09.000Z' });
708
+ return got.body.state === 'started' && calls.filter((c) => c.op === 'run').length === 2;
709
+ }, { runtime });
710
+ })();
711
+ return Promise.all([l1, l2, l3, l4]).then((legs) => legs.every(Boolean));
712
+ }),
713
+ done('fly.execution.launch_failure_faithful', 'execution', 'A launch the execution plane REFUSES (bad image) -> 422 "failed to launch VM: …" and the machine lands in state failed — never a fake started — with its mounts ATTACHED (consistent with the persisted config, so no second machine can claim them through the failure window)', 'api', 'core', () => {
714
+ const { runtime } = fakeRuntime({ async run() { throw new Error('pull access denied for no/such-image'); } });
715
+ return withRoot(async (h) => {
716
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
717
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
718
+ const volId = vol.body.id;
719
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'no/such-image', mounts: [{ volume: volId, path: '/d' }] } } });
720
+ const list = await h({ m: 'GET', p: '/v1/apps/a1/machines?state=failed' });
721
+ const failedId = (list.body[0] ?? {}).id;
722
+ // Attach happened BEFORE the launch, so the failed machine still holds its volume and a
723
+ // second machine cannot steal it while the config claims it (§9 round two, finding 2).
724
+ const volAfter = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}` });
725
+ const thief = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'no/such-image', mounts: [{ volume: volId, path: '/d' }] } } });
726
+ return r.status === 422 && String(r.body.error).includes('failed to launch VM')
727
+ && String(r.body.error).includes('pull access denied')
728
+ && list.body.length === 1
729
+ && volAfter.body.attached_machine_id === failedId
730
+ && thief.status === 422;
731
+ }, { runtime });
732
+ }),
733
+ done('fly.execution.exec_roundtrip', 'execution', 'POST exec on a started machine runs the command through the execution plane and answers the flydv1.ExecResponse shape {exit_code, stdout, stderr}', 'api', 'core', () => {
734
+ const { runtime, calls } = fakeRuntime();
735
+ return withRoot(async (h) => {
736
+ const id = await seedMachine(h, 'a1');
737
+ const r = await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/exec`, b: { command: ['uname', '-a'] } });
738
+ const b = r.body;
739
+ const execCall = calls.find((c) => c.op === 'exec');
740
+ return r.status === 200 && b.exit_code === 0 && b.stdout === 'ran:uname -a' && b.stderr === ''
741
+ && Array.isArray(execCall?.args[0]) && execCall.args[0].join(' ') === 'uname -a';
742
+ }, { runtime });
743
+ }),
744
+ done('fly.execution.exec_virtual_refuses', 'execution', 'exec against the pure-ledger virtual plane refuses loudly (vendor-enveloped 400 naming the virtual runtime) — never a fabricated {exit_code:0}', 'api', 'common', () => withRoot(async (h) => {
745
+ const id = await seedMachine(h);
746
+ const r = await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/exec`, b: { command: ['ls'] } });
747
+ return r.status === 400 && String(r.body.error).includes('virtual');
748
+ })),
749
+ done('fly.execution.update_relaunches', 'execution', 'Updating a STARTED machine reaps the old container (runtime.remove) and launches the new config (runtime.run with the new image)', 'api', 'common', () => {
750
+ const { runtime, calls } = fakeRuntime();
751
+ return withRoot(async (h) => {
752
+ const id = await seedMachine(h, 'a1', { image: 'app:v1' });
753
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}`, b: { config: { image: 'app:v2' } } });
754
+ const runs = calls.filter((c) => c.op === 'run').map((c) => c.args[0].image);
755
+ return runs.join(',') === 'app:v1,app:v2' && calls.some((c) => c.op === 'remove');
756
+ }, { runtime });
757
+ }),
758
+ done('fly.execution.suspend_pauses', 'execution', 'suspend drives runtime.pause() and resume drives runtime.unpause() when the plane supports them', 'api', 'niche', () => {
759
+ const { runtime, calls } = fakeRuntime();
760
+ return withRoot(async (h) => {
761
+ const id = await seedMachine(h, 'a1');
762
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/suspend` });
763
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${id}/start`, at: '2026-01-01T00:00:01.000Z' });
764
+ return calls.some((c) => c.op === 'pause') && calls.some((c) => c.op === 'unpause');
765
+ }, { runtime });
766
+ }),
767
+ done('fly.execution.virtual_default_offline', 'execution', 'With NO runtime injected the pure-ledger virtual plane serves the whole lifecycle offline (container ref virtual:<id>, no Docker anywhere)', 'api', 'core', () => withRoot(async (h) => {
768
+ const id = await seedMachine(h);
769
+ const got = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
770
+ const b = got.body;
771
+ await h({ m: 'POST', p: `/v1/apps/twin-app/machines/${id}/stop` });
772
+ const stopped = await h({ m: 'GET', p: `/v1/apps/twin-app/machines/${id}` });
773
+ return b.state === 'started' && b.twin_runtime === 'virtual' && stopped.body.state === 'stopped';
774
+ })),
775
+ // ── VOLUMES ───────────────────────────────────────────────────────────────────────────────
776
+ done('fly.volumes.create', 'volumes', 'POST volumes creates a volume: vol_-prefixed id, size_gb/region/fstype/zone echoed, state created', 'api', 'core', () => withRoot(async (h) => {
777
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
778
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'pg_data', size_gb: 10, region: 'lhr' } });
779
+ const b = r.body;
780
+ return r.status === 200 && /^vol_[0-9a-hj-km-np-tv-z]{20}$/.test(b.id) && b.name === 'pg_data'
781
+ && b.size_gb === 10 && b.region === 'lhr' && b.state === 'created' && b.fstype === 'ext4'
782
+ && b.attached_machine_id === null;
783
+ })),
784
+ done('fly.volumes.create_requires_name_422', 'volumes', 'A volume create without a name -> 422; an invalid name (dashes) -> 422', 'api', 'common', () => withRoot(async (h) => {
785
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
786
+ const missing = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { size_gb: 1 } });
787
+ const invalid = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'bad-name', size_gb: 1 } });
788
+ return missing.status === 422 && invalid.status === 422;
789
+ })),
790
+ done('fly.volumes.get_and_list', 'volumes', 'GET volumes/{id} returns the Volume; the list is a BARE array (fly-go decodes []Volume); unknown id -> 404', 'api', 'core', () => withRoot(async (h) => {
791
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
792
+ const created = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 3 } });
793
+ const id = created.body.id;
794
+ const got = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${id}` });
795
+ const list = await h({ m: 'GET', p: '/v1/apps/a1/volumes' });
796
+ const missing = await h({ m: 'GET', p: '/v1/apps/a1/volumes/vol_00000000000000000000' });
797
+ return got.status === 200 && got.body.id === id && got.body.bytes_total === 3 * 1024 * 1024 * 1024
798
+ && Array.isArray(list.body) && list.body.some((v) => v.id === id)
799
+ && missing.status === 404;
800
+ })),
801
+ done('fly.volumes.update', 'volumes', 'PUT volumes/{id} updates snapshot_retention/auto_backup_enabled and returns the Volume', 'api', 'niche', () => withRoot(async (h) => {
802
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
803
+ const created = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
804
+ const id = created.body.id;
805
+ const r = await h({ m: 'PUT', p: `/v1/apps/a1/volumes/${id}`, b: { snapshot_retention: 14, auto_backup_enabled: false } });
806
+ const b = r.body;
807
+ return r.status === 200 && b.snapshot_retention === 14 && b.auto_backup_enabled === false;
808
+ })),
809
+ done('fly.volumes.delete', 'volumes', 'DELETE volumes/{id} on an unattached volume returns the Volume as pending_destroy and it stays listed (live-verified 2026-09-03) until the platform purges it (⚠ purge window doc-unverified; 60 s of world time modeled), after which it is gone from the list', 'api', 'core', () => withRoot(async (h) => {
810
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
811
+ const created = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
812
+ const id = created.body.id;
813
+ const del = await h({ m: 'DELETE', p: `/v1/apps/a1/volumes/${id}` });
814
+ const list = await h({ m: 'GET', p: '/v1/apps/a1/volumes' });
815
+ const later = new Date(Date.parse(OCCURRED_AT) + 61_000).toISOString();
816
+ const purged = await h({ m: 'GET', p: '/v1/apps/a1/volumes', at: later });
817
+ return del.status === 200 && del.body.state === 'pending_destroy'
818
+ && list.body.length === 1 && list.body[0].state === 'pending_destroy'
819
+ && purged.body.length === 0;
820
+ })),
821
+ done('fly.volumes.delete_attached_400', 'volumes', 'Deleting a volume that is ATTACHED to a machine -> 400 naming the machine (attach semantics enforced)', 'api', 'core', () => withRoot(async (h) => {
822
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
823
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
824
+ const volId = vol.body.id;
825
+ const m = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/data' }] } } });
826
+ const refused = await h({ m: 'DELETE', p: `/v1/apps/a1/volumes/${volId}` });
827
+ return refused.status === 400 && String(refused.body.error).includes(String(m.body.id));
828
+ })),
829
+ done('fly.volumes.detach_on_machine_destroy', 'volumes', 'Attachments RECONCILE: destroying the machine detaches its volumes, and an UPDATE that drops the mount detaches too (attached_machine_id null again; delete then succeeds) — dirty-state attach/detach round-trip on both paths', 'api', 'common', () => withRoot(async (h) => {
830
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
831
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
832
+ const volId = vol.body.id;
833
+ const m = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/data' }] } } });
834
+ const mid = m.body.id;
835
+ // UPDATE drops the mount → the volume detaches without destroying the machine.
836
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${mid}`, b: { config: { image: 'pg:16' } } });
837
+ const dropped = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}` });
838
+ // Re-attach via update, then destroy the machine → detached again, delete succeeds.
839
+ await h({ m: 'POST', p: `/v1/apps/a1/machines/${mid}`, b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/data' }] } } });
840
+ const reattached = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}` });
841
+ await h({ m: 'DELETE', p: `/v1/apps/a1/machines/${mid}?force=true` });
842
+ const after = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}` });
843
+ const del = await h({ m: 'DELETE', p: `/v1/apps/a1/volumes/${volId}` });
844
+ return dropped.body.attached_machine_id === null
845
+ && reattached.body.attached_machine_id === mid
846
+ && after.body.attached_machine_id === null && del.status === 200;
847
+ })),
848
+ done('fly.volumes.mount_unknown_404', 'volumes', 'The mount rulebook holds on CREATE **and UPDATE**: an unknown volume -> 404, a volume attached to another machine -> 422 — on both paths (a machine keeps its own attachment across updates)', 'api', 'common', () => withRoot(async (h) => {
849
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
850
+ const unknown = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg', mounts: [{ volume: 'vol_nope00000000000000', path: '/d' }] } } });
851
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 1 } });
852
+ const volId = vol.body.id;
853
+ const owner = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg', mounts: [{ volume: volId, path: '/d' }] } } });
854
+ const taken = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg', mounts: [{ volume: volId, path: '/d' }] } } });
855
+ // UPDATE legs: a second machine cannot STEAL the attached volume via update, nor mount an
856
+ // unknown one; the owner CAN keep its own attachment through an update.
857
+ const thief = await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg' } } });
858
+ const steal = await h({ m: 'POST', p: `/v1/apps/a1/machines/${thief.body.id}`, b: { config: { image: 'pg', mounts: [{ volume: volId, path: '/d' }] } } });
859
+ const ghost = await h({ m: 'POST', p: `/v1/apps/a1/machines/${thief.body.id}`, b: { config: { image: 'pg', mounts: [{ volume: 'vol_nope00000000000000', path: '/d' }] } } });
860
+ const keepOwn = await h({ m: 'POST', p: `/v1/apps/a1/machines/${owner.body.id}`, b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/d' }] } } });
861
+ return unknown.status === 404 && taken.status === 422
862
+ && steal.status === 422 && ghost.status === 404 && keepOwn.status === 200;
863
+ })),
864
+ done('fly.volumes.extend', 'volumes', 'PUT extend grows size_gb -> {needs_restart, volume}; needs_restart true iff attached (BOTH branches asserted); shrinking -> 400', 'api', 'common', () => withRoot(async (h) => {
865
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
866
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 2 } });
867
+ const volId = vol.body.id;
868
+ const grown = await h({ m: 'PUT', p: `/v1/apps/a1/volumes/${volId}/extend`, b: { size_gb: 5 } });
869
+ const shrink = await h({ m: 'PUT', p: `/v1/apps/a1/volumes/${volId}/extend`, b: { size_gb: 1 } });
870
+ await h({ m: 'POST', p: '/v1/apps/a1/machines', b: { config: { image: 'pg:16', mounts: [{ volume: volId, path: '/d' }] } } });
871
+ const attachedGrow = await h({ m: 'PUT', p: `/v1/apps/a1/volumes/${volId}/extend`, b: { size_gb: 8 } });
872
+ const gb = grown.body;
873
+ const ab = attachedGrow.body;
874
+ return grown.status === 200 && gb.volume.size_gb === 5 && gb.needs_restart === false
875
+ && shrink.status === 400
876
+ && attachedGrow.status === 200 && ab.needs_restart === true && ab.volume.size_gb === 8;
877
+ })),
878
+ done('fly.volumes.snapshots', 'volumes', 'GET snapshots lists (bare array); POST snapshots appends a vs_-prefixed snapshot with the volume\'s size recorded', 'api', 'niche', () => withRoot(async (h) => {
879
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
880
+ const vol = await h({ m: 'POST', p: '/v1/apps/a1/volumes', b: { name: 'data', size_gb: 2 } });
881
+ const volId = vol.body.id;
882
+ const empty = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}/snapshots` });
883
+ await h({ m: 'POST', p: `/v1/apps/a1/volumes/${volId}/snapshots` });
884
+ const one = await h({ m: 'GET', p: `/v1/apps/a1/volumes/${volId}/snapshots` });
885
+ const snap = one.body[0];
886
+ return Array.isArray(empty.body) && empty.body.length === 0
887
+ && one.body.length === 1 && String(snap.id).startsWith('vs_') && snap.volume_size === 2 * 1024 * 1024 * 1024;
888
+ })),
889
+ todo('fly.volumes.fork_from_source', 'volumes', 'CreateVolumeRequest.source_volume_id forks an existing volume', 'api', 'niche'),
890
+ todo('fly.volumes.restore_from_snapshot', 'volumes', 'CreateVolumeRequest.snapshot_id restores a volume from a snapshot', 'api', 'niche'),
891
+ todo('fly.volumes.list_summary', 'volumes', 'volumes list ?summary=true', 'api', 'niche'),
892
+ todo('fly.volumes.docker_volume_materialization', 'volumes', 'The docker execution plane materializes volumes as named docker volumes at machine-run time (proven only in the self-skipping real-Docker integration test today)', 'api', 'common'),
893
+ // ── SECRETS (app secrets API) ─────────────────────────────────────────────────────────────
894
+ done('fly.secrets.set_and_list', 'secrets', 'POST secrets/{name} {value} -> 201 with digest and NO value; the list wraps {secrets:[…]} hiding values; show_secrets=true reveals them', 'api', 'core', () => withRoot(async (h) => {
895
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
896
+ const set = await h({ m: 'POST', p: '/v1/apps/a1/secrets/DATABASE_URL', b: { value: 'postgres://secret' } });
897
+ const list = await h({ m: 'GET', p: '/v1/apps/a1/secrets' });
898
+ const shown = await h({ m: 'GET', p: '/v1/apps/a1/secrets/DATABASE_URL?show_secrets=true' });
899
+ const sb = set.body;
900
+ const row = list.body.secrets[0];
901
+ return set.status === 201 && sb.name === 'DATABASE_URL' && typeof sb.digest === 'string' && sb.value === undefined
902
+ && row.name === 'DATABASE_URL' && row.value === undefined
903
+ && shown.body.value === 'postgres://secret';
904
+ })),
905
+ done('fly.secrets.bulk_update', 'secrets', 'POST secrets {values:{…}} bulk-upserts -> {secrets, version}, and the app-wide version MOVES FORWARD on every update operation', 'api', 'common', () => withRoot(async (h) => {
906
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
907
+ const r = await h({ m: 'POST', p: '/v1/apps/a1/secrets', b: { values: { A: '1', B: '2' } } });
908
+ const r2 = await h({ m: 'POST', p: '/v1/apps/a1/secrets', b: { values: { A: '3' } } });
909
+ const b = r.body;
910
+ return r.status === 200 && b.secrets.length === 2 && typeof b.version === 'number'
911
+ && r2.body.version > b.version;
912
+ })),
913
+ done('fly.secrets.delete_and_404', 'secrets', 'DELETE secrets/{name} -> {version}; the secret 404s afterwards; deleting/reading an unknown name -> 404', 'api', 'common', () => withRoot(async (h) => {
914
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
915
+ await h({ m: 'POST', p: '/v1/apps/a1/secrets/DOOMED', b: { value: 'x' } });
916
+ const del = await h({ m: 'DELETE', p: '/v1/apps/a1/secrets/DOOMED' });
917
+ const gone = await h({ m: 'GET', p: '/v1/apps/a1/secrets/DOOMED' });
918
+ const never = await h({ m: 'DELETE', p: '/v1/apps/a1/secrets/NEVER_WAS' });
919
+ return del.status === 200 && typeof del.body.version === 'number' && gone.status === 404 && never.status === 404;
920
+ })),
921
+ done('fly.secrets.version_bumps', 'secrets', 'The app secrets version is a MONOTONIC app-wide counter: every set/delete bumps it and it NEVER goes backwards, even across a delete + re-add (dirty-state: no tombstone swallow, no version regression)', 'api', 'common', () => withRoot(async (h) => {
922
+ await h({ m: 'POST', p: '/v1/apps', b: { app_name: 'a1', org_slug: 'personal' } });
923
+ const v1 = await h({ m: 'POST', p: '/v1/apps/a1/secrets/KEY', b: { value: 'one' } });
924
+ const v2 = await h({ m: 'POST', p: '/v1/apps/a1/secrets/KEY', b: { value: 'two' } });
925
+ const v3 = await h({ m: 'DELETE', p: '/v1/apps/a1/secrets/KEY' });
926
+ const re = await h({ m: 'POST', p: '/v1/apps/a1/secrets/KEY', b: { value: 'three' } });
927
+ const shown = await h({ m: 'GET', p: '/v1/apps/a1/secrets/KEY?show_secrets=true' });
928
+ return v1.body.version === 1 && v2.body.version === 2
929
+ && v3.body.version === 3
930
+ && re.status === 201 && re.body.version === 4
931
+ && shown.body.value === 'three';
932
+ })),
933
+ todo('fly.secrets.min_version_filter', 'secrets', 'secrets list/get ?min_version= filtering', 'api', 'niche'),
934
+ todo('fly.secrets.generate', 'secrets', 'POST secretkeys/{name}/generate mints a random secret value server-side', 'api', 'niche'),
935
+ // ── SECRETKEYS (typed keys: encrypt/decrypt/sign/verify) ──────────────────────────────────
936
+ todo('fly.secretkeys.list', 'secretkeys', 'GET secretkeys lists typed secret keys', 'api', 'niche'),
937
+ todo('fly.secretkeys.set', 'secretkeys', 'POST secretkeys/{name} creates a typed key', 'api', 'niche'),
938
+ todo('fly.secretkeys.sign_verify', 'secretkeys', 'POST secretkeys/{name}/sign + /verify round-trip', 'api', 'niche'),
939
+ todo('fly.secretkeys.encrypt_decrypt', 'secretkeys', 'POST secretkeys/{name}/encrypt + /decrypt round-trip', 'api', 'niche'),
940
+ // ── TLS CERTIFICATES (metadata CRUD) ─────────────────────────────────────────────────────
941
+ todo('fly.certificates.list', 'certificates', 'GET certificates lists an app\'s certificates', 'api', 'niche'),
942
+ todo('fly.certificates.add_acme', 'certificates', 'POST certificates/acme registers a hostname for managed issuance', 'api', 'niche'),
943
+ todo('fly.certificates.get', 'certificates', 'GET certificates/{hostname} shows cert status + DNS requirements', 'api', 'niche'),
944
+ todo('fly.certificates.check', 'certificates', 'POST certificates/{hostname}/check re-validates DNS', 'api', 'niche'),
945
+ todo('fly.certificates.delete', 'certificates', 'DELETE certificates/{hostname}', 'api', 'niche'),
946
+ todo('fly.certificates.custom', 'certificates', 'POST certificates/custom + the per-hostname custom/acme DELETE variants', 'api', 'niche'),
947
+ // ── IP ASSIGNMENTS ────────────────────────────────────────────────────────────────────────
948
+ todo('fly.ip_assignments.list', 'ip_assignments', 'GET ip_assignments lists the app\'s allocated IPs', 'api', 'niche'),
949
+ todo('fly.ip_assignments.create', 'ip_assignments', 'POST ip_assignments allocates a (shared/dedicated) IP', 'api', 'niche'),
950
+ todo('fly.ip_assignments.delete', 'ip_assignments', 'DELETE ip_assignments/{ip} releases an IP', 'api', 'niche'),
951
+ // ── TOKENS ────────────────────────────────────────────────────────────────────────────────
952
+ todo('fly.tokens.current', 'tokens', 'GET tokens/current introspects the presented token', 'api', 'niche'),
953
+ todo('fly.tokens.authenticate', 'tokens', 'POST tokens/authenticate validates a token', 'api', 'niche'),
954
+ todo('fly.tokens.authorize', 'tokens', 'POST tokens/authorize checks a token against a resource action', 'api', 'niche'),
955
+ todo('fly.tokens.kms', 'tokens', 'POST tokens/kms mints a KMS-scoped token', 'api', 'niche'),
956
+ // ── ORGANIZATIONS ─────────────────────────────────────────────────────────────────────────
957
+ todo('fly.orgs.machines_list', 'orgs', 'GET orgs/{slug}/machines lists machines across the org (cursor-paginated)', 'api', 'common'),
958
+ todo('fly.orgs.volumes_list', 'orgs', 'GET orgs/{slug}/volumes lists volumes across the org (cursor-paginated)', 'api', 'niche'),
959
+ // ── PLATFORM ──────────────────────────────────────────────────────────────────────────────
960
+ todo('fly.platform.regions', 'platform', 'GET platform/regions lists Fly regions with capability flags', 'api', 'niche'),
961
+ todo('fly.platform.placements', 'platform', 'POST platform/placements previews regional capacity for a guest spec', 'api', 'niche'),
962
+ // ── MANAGED POSTGRES ──────────────────────────────────────────────────────────────────────
963
+ todo('fly.postgres.clusters_crud', 'postgres', 'POST/GET/DELETE /v1/postgres clusters (Managed Postgres)', 'api', 'niche'),
964
+ todo('fly.postgres.databases', 'postgres', 'Cluster databases list/create/delete', 'api', 'niche'),
965
+ todo('fly.postgres.users', 'postgres', 'Cluster users CRUD + credentials + rotate_password', 'api', 'niche'),
966
+ todo('fly.postgres.backups', 'postgres', 'Cluster backups list/create + restore/fork', 'api', 'niche'),
967
+ todo('fly.postgres.attachments', 'postgres', 'Attach/detach a cluster to an app (connection-string injection)', 'api', 'niche'),
968
+ todo('fly.postgres.extensions', 'postgres', 'Database extensions list/enable/disable', 'api', 'niche'),
969
+ // ── CONNECTOR (pull over the injected executor; D6/D7/D8) ─────────────────────────────────
970
+ done('fly.connector.pull_apps_machines_volumes', 'connector', 'syncFlyFromReal pulls the org\'s apps, then each app\'s machines + volumes, and folds them through syncPull — the twin then SERVES the pulled state (fresh-root proof; app rows are keyed by NAME, so a pulled app whose name a LOCAL create already claimed keeps the local row — local writes win the projection; disclosed in pull-audit.json)', 'connector', 'core', () => {
971
+ const root = mkdtempSync(join(tmpdir(), 'fly-conn-'));
972
+ const exec = async (_m, path) => {
973
+ if (/^\/v1\/apps\/[^/?]+$/.test(path))
974
+ return { status: 200, body: { id: path.split('/')[3], name: path.split('/')[3], network: 'default', status: 'deployed', internal_numeric_id: 424242, organization: { slug: 'acme', name: 'acme' } } };
975
+ if (path.startsWith('/v1/apps?'))
976
+ return { status: 200, body: { total_apps: 1, apps: [{ id: 'real-app', name: 'real-app', network: 'default', status: 'deployed', machine_count: 1 }] } };
977
+ if (path.endsWith('/machines'))
978
+ return { status: 200, body: [{ id: 'e28697ce9d3986', name: 'real-web', state: 'started', region: 'sjc', instance_id: '01JGXREALINSTANCE0000000AB', private_ip: 'fdaa:0:1:a7b::2', config: { image: 'real/app:v9' }, created_at: '2026-01-01T00:00:00Z', events: [] }] };
979
+ if (path.endsWith('/volumes'))
980
+ return { status: 200, body: [{ id: 'vol_realrealrealrealreal', name: 'real_data', state: 'created', region: 'sjc', size_gb: 7 }] };
981
+ return { status: 404, body: { error: 'not found' } };
982
+ };
983
+ return verifyBoundary('fly.connector.pull', async () => {
984
+ try {
985
+ const res = await syncFlyFromReal(exec, { root, occurredAt: OCCURRED_AT, orgSlug: 'acme' });
986
+ const machine = await handleFlyTwinRequest({ method: 'GET', path: '/v1/apps/real-app/machines/e28697ce9d3986', root, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
987
+ const vol = await handleFlyTwinRequest({ method: 'GET', path: '/v1/apps/real-app/volumes/vol_realrealrealrealreal', root, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
988
+ const mb = machine.body;
989
+ return res.observed === 3 && res.deltasAppended > 0
990
+ && machine.status === 200 && mb.name === 'real-web' && mb.state === 'started'
991
+ && mb.config?.image === 'real/app:v9' && mb.twin_runtime === 'real-fly'
992
+ && vol.status === 200 && vol.body.size_gb === 7;
993
+ }
994
+ finally {
995
+ rmSync(root, { recursive: true, force: true });
996
+ }
997
+ });
998
+ }),
999
+ done('fly.connector.pull_idempotent', 'connector', 'A re-pull of IDENTICAL real state appends ZERO deltas (syncPull shadow-diff, D6)', 'connector', 'core', () => {
1000
+ const root = mkdtempSync(join(tmpdir(), 'fly-conn-'));
1001
+ const exec = async (_m, path) => {
1002
+ if (/^\/v1\/apps\/[^/?]+$/.test(path))
1003
+ return { status: 200, body: { id: path.split('/')[3], name: path.split('/')[3], network: 'default', status: 'deployed', internal_numeric_id: 424242, organization: { slug: 'acme', name: 'acme' } } };
1004
+ if (path.startsWith('/v1/apps?'))
1005
+ return { status: 200, body: { total_apps: 1, apps: [{ id: 'same-app', name: 'same-app' }] } };
1006
+ if (path.endsWith('/machines'))
1007
+ return { status: 200, body: [{ id: 'aaaaaaaaaaaaaa', name: 'w', state: 'stopped', region: 'iad', config: { image: 'x:1' }, events: [] }] };
1008
+ if (path.endsWith('/volumes'))
1009
+ return { status: 200, body: [] };
1010
+ return { status: 404, body: { error: 'not found' } };
1011
+ };
1012
+ return verifyBoundary('fly.connector.idempotent', async () => {
1013
+ try {
1014
+ const first = await syncFlyFromReal(exec, { root, occurredAt: OCCURRED_AT });
1015
+ const again = await syncFlyFromReal(exec, { root, occurredAt: '2026-01-01T00:10:00.000Z' });
1016
+ return first.deltasAppended > 0 && again.deltasAppended === 0 && again.observed === 2;
1017
+ }
1018
+ finally {
1019
+ rmSync(root, { recursive: true, force: true });
1020
+ }
1021
+ });
1022
+ }),
1023
+ done('fly.connector.pull_refuses_failure', 'connector', 'A refused pull (401/500 from the vendor) THROWS instead of folding an empty account over real observed state (ADDING_A_TWIN §6)', 'connector', 'core', () => {
1024
+ const root = mkdtempSync(join(tmpdir(), 'fly-conn-'));
1025
+ return verifyBoundary('fly.connector.refusal', async () => {
1026
+ try {
1027
+ const seeded = async (_m, path) => {
1028
+ if (/^\/v1\/apps\/[^/?]+$/.test(path))
1029
+ return { status: 200, body: { id: path.split('/')[3], name: path.split('/')[3], network: 'default', status: 'deployed', internal_numeric_id: 424242, organization: { slug: 'acme', name: 'acme' } } };
1030
+ if (path.startsWith('/v1/apps?'))
1031
+ return { status: 200, body: { total_apps: 1, apps: [{ id: 'keep-app', name: 'keep-app' }] } };
1032
+ if (path.endsWith('/machines'))
1033
+ return { status: 200, body: [{ id: 'bbbbbbbbbbbbbb', name: 'keep', state: 'started', region: 'iad', config: { image: 'x:1' }, events: [] }] };
1034
+ if (path.endsWith('/volumes'))
1035
+ return { status: 200, body: [] };
1036
+ return { status: 404, body: { error: 'not found' } };
1037
+ };
1038
+ await syncFlyFromReal(seeded, { root, occurredAt: OCCURRED_AT });
1039
+ const refusing = async () => ({ status: 401, body: { error: 'unauthorized' } });
1040
+ let threw = false;
1041
+ try {
1042
+ await syncFlyFromReal(refusing, { root, occurredAt: '2026-01-01T01:00:00.000Z' });
1043
+ }
1044
+ catch (e) {
1045
+ threw = e instanceof Error && e.message.includes('refused');
1046
+ }
1047
+ const still = await handleFlyTwinRequest({ method: 'GET', path: '/v1/apps/keep-app/machines/bbbbbbbbbbbbbb', root, occurredAt: OCCURRED_AT, headers: { authorization: 'Bearer t' } });
1048
+ return threw && still.status === 200 && still.body.name === 'keep';
1049
+ }
1050
+ finally {
1051
+ rmSync(root, { recursive: true, force: true });
1052
+ }
1053
+ });
1054
+ }),
1055
+ done('fly.connector.budget_ceiling_fail_closed', 'connector', 'liveFlyExecute charges the shared FlyBudget BEFORE each request: past the ceiling it THROWS FlyBudgetError and the counting fake fetch proves the refused call NEVER went out (D8)', 'connector', 'core', () => {
1056
+ const root = mkdtempSync(join(tmpdir(), 'fly-budget-'));
1057
+ return verifyBoundary('fly.connector.budget', async () => {
1058
+ try {
1059
+ let calls = 0;
1060
+ const fakeFetch = (async () => {
1061
+ calls++;
1062
+ return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' } });
1063
+ });
1064
+ const budget = new FlyBudget({ path: join(root, 'ledger.json'), now: () => Date.parse(OCCURRED_AT) });
1065
+ const exec = liveFlyExecute('fm2_faketoken', 'https://api.machines.test', { fetchImpl: fakeFetch, budget });
1066
+ const allowed = Math.floor(FLY_BUDGET_CEILING / FLY_CALL_WEIGHTS.other);
1067
+ for (let i = 0; i < allowed; i++)
1068
+ await exec('GET', '/v1/apps/a/machines');
1069
+ if (calls !== allowed)
1070
+ return false;
1071
+ let refused = false;
1072
+ try {
1073
+ await exec('GET', '/v1/apps/a/machines');
1074
+ }
1075
+ catch (e) {
1076
+ refused = e instanceof FlyBudgetError;
1077
+ }
1078
+ // The count is the proof: the refused call must never have reached the fake vendor.
1079
+ return refused && calls === allowed;
1080
+ }
1081
+ finally {
1082
+ rmSync(root, { recursive: true, force: true });
1083
+ }
1084
+ });
1085
+ }),
1086
+ done('fly.connector.budget_persists_across_processes', 'connector', 'A FRESH FlyBudget over the SAME ledger file starts with the window already spent — a new process gets no new allowance (D8 persistence)', 'connector', 'core', () => {
1087
+ const root = mkdtempSync(join(tmpdir(), 'fly-budget-'));
1088
+ return verifyBoundary('fly.connector.budget_persist', async () => {
1089
+ try {
1090
+ let calls = 0;
1091
+ const fakeFetch = (async () => { calls++; return new Response('[]', { status: 200 }); });
1092
+ const path = join(root, 'ledger.json');
1093
+ const now = () => Date.parse(OCCURRED_AT);
1094
+ const exec1 = liveFlyExecute('fm2_tok', 'https://api.machines.test', { fetchImpl: fakeFetch, budget: new FlyBudget({ path, now }) });
1095
+ const allowed = Math.floor(FLY_BUDGET_CEILING / FLY_CALL_WEIGHTS.other);
1096
+ for (let i = 0; i < allowed; i++)
1097
+ await exec1('GET', '/v1/apps/a/machines');
1098
+ // "New process": a fresh budget instance over the same ledger.
1099
+ const exec2 = liveFlyExecute('fm2_tok', 'https://api.machines.test', { fetchImpl: fakeFetch, budget: new FlyBudget({ path, now }) });
1100
+ let refused = false;
1101
+ try {
1102
+ await exec2('GET', '/v1/apps/a/machines');
1103
+ }
1104
+ catch (e) {
1105
+ refused = e instanceof FlyBudgetError;
1106
+ }
1107
+ return refused && calls === allowed;
1108
+ }
1109
+ finally {
1110
+ rmSync(root, { recursive: true, force: true });
1111
+ }
1112
+ });
1113
+ }),
1114
+ done('fly.connector.budget_cooldown_on_429', 'connector', 'A vendor 429 with Retry-After arms a PERSISTED cooldown: the next call is refused without touching the vendor (count unchanged)', 'connector', 'common', () => {
1115
+ const root = mkdtempSync(join(tmpdir(), 'fly-budget-'));
1116
+ return verifyBoundary('fly.connector.cooldown', async () => {
1117
+ try {
1118
+ let calls = 0;
1119
+ const fakeFetch = (async () => {
1120
+ calls++;
1121
+ return new Response(JSON.stringify({ error: 'rate limited' }), { status: 429, headers: { 'retry-after': '30' } });
1122
+ });
1123
+ const budget = new FlyBudget({ path: join(root, 'ledger.json'), now: () => Date.parse(OCCURRED_AT) });
1124
+ const exec = liveFlyExecute('fm2_tok', 'https://api.machines.test', { fetchImpl: fakeFetch, budget });
1125
+ await exec('GET', '/v1/apps/a/machines'); // the 429 lands and arms the cooldown
1126
+ let refused = false;
1127
+ try {
1128
+ await exec('GET', '/v1/apps/a/machines');
1129
+ }
1130
+ catch (e) {
1131
+ refused = e instanceof FlyBudgetError;
1132
+ }
1133
+ return refused && calls === 1;
1134
+ }
1135
+ finally {
1136
+ rmSync(root, { recursive: true, force: true });
1137
+ }
1138
+ });
1139
+ }),
1140
+ done('fly.connector.create_weighs_heavier', 'connector', 'Machine/app/volume creation POSTs are priced at weight 3 (billable provisioning) while reads cost 2 — the declared rules, exercised through the live executor\'s own pricing', 'connector', 'common', () => {
1141
+ const root = mkdtempSync(join(tmpdir(), 'fly-budget-'));
1142
+ return verifyBoundary('fly.connector.weights', async () => {
1143
+ try {
1144
+ let calls = 0;
1145
+ const fakeFetch = (async () => { calls++; return new Response('{}', { status: 200 }); });
1146
+ const budget = new FlyBudget({ path: join(root, 'ledger.json'), now: () => Date.parse(OCCURRED_AT) });
1147
+ const exec = liveFlyExecute('fm2_tok', 'https://api.machines.test', { fetchImpl: fakeFetch, budget });
1148
+ const allowedCreates = Math.floor(FLY_BUDGET_CEILING / FLY_CALL_WEIGHTS.create);
1149
+ for (let i = 0; i < allowedCreates; i++)
1150
+ await exec('POST', '/v1/apps/a/machines', { config: { image: 'x' } });
1151
+ let refused = false;
1152
+ try {
1153
+ await exec('POST', '/v1/apps/a/machines', { config: { image: 'x' } });
1154
+ }
1155
+ catch (e) {
1156
+ refused = e instanceof FlyBudgetError;
1157
+ }
1158
+ return refused && calls === allowedCreates && allowedCreates === 20;
1159
+ }
1160
+ finally {
1161
+ rmSync(root, { recursive: true, force: true });
1162
+ }
1163
+ });
1164
+ }),
1165
+ done('fly.connector.push', 'connector', 'performFlyAction carries ONE entry across through the kernel\'s executor — a machine the twin created becomes POST /v1/apps/{app}/machines with its mount\'s volume resolved to the vendor\'s id, and the answer\'s id comes back as the externalId; the twin\'s own entries (a lease, a purge) cross nothing (protocol 2)', 'connector', 'common', () => {
1166
+ const root = mkdtempSync(join(tmpdir(), 'fly-perform-'));
1167
+ return verifyBoundary('fly.connector.perform', async () => {
1168
+ try {
1169
+ const { performFlyAction } = await import("./fly-connector.js");
1170
+ const auth = { authorization: 'Bearer t' };
1171
+ await handleFlyTwinRequest({ method: 'POST', path: '/v1/apps', body: JSON.stringify({ app_name: 'a1', org_slug: 'acme' }), root, occurredAt: OCCURRED_AT, headers: auth });
1172
+ const vol = await handleFlyTwinRequest({ method: 'POST', path: '/v1/apps/a1/volumes', body: JSON.stringify({ name: 'data', region: 'sjc', size_gb: 1 }), root, occurredAt: OCCURRED_AT, headers: auth });
1173
+ const volId = String(vol.body.id ?? '');
1174
+ const made = await handleFlyTwinRequest({ method: 'POST', path: '/v1/apps/a1/machines', body: JSON.stringify({ name: 'web', region: 'sjc', config: { image: 'app:v1', mounts: [{ volume: volId, path: '/data' }] } }), root, occurredAt: OCCURRED_AT, headers: auth });
1175
+ const m = made.body;
1176
+ if (made.status !== 200 || typeof m.id !== 'string')
1177
+ return false;
1178
+ const wire = [];
1179
+ const execute = async (r) => { wire.push(r); return { status: 200, headers: {}, body: JSON.stringify({ id: 'e2real0000000001' }) }; };
1180
+ const ctx = { resolve: (_t, l) => (l === volId ? 'vol_real' : l), service: 'fly', root };
1181
+ const out = await performFlyAction(execute, { operation: 'machine.create', subject: { type: 'machine', id: m.id }, fields: { app_name: 'a1', name: m.name, region: m.region, config: m.config } }, ctx);
1182
+ const own = await performFlyAction(execute, { operation: 'machine.purge', subject: { type: 'machine', id: m.id }, fields: { app_name: 'a1' } }, ctx);
1183
+ const sent = wire[0] ? JSON.parse(Buffer.from(wire[0].body ?? '{}').toString()) : undefined;
1184
+ return out.externalId === 'e2real0000000001' && wire.length === 1 && wire[0].method === 'POST' && wire[0].path === '/v1/apps/a1/machines'
1185
+ && sent?.config?.mounts?.[0]?.volume === 'vol_real' && own.externalId === m.id && wire.length === 1;
1186
+ }
1187
+ finally {
1188
+ rmSync(root, { recursive: true, force: true });
1189
+ }
1190
+ });
1191
+ }),
1192
+ todo('fly.connector.org_inventory_pull', 'connector', 'Pull via /v1/orgs/{slug}/machines (cursor-paginated org-wide inventory) instead of per-app walks', 'connector', 'niche'),
1193
+ // ── CONFORMANCE ───────────────────────────────────────────────────────────────────────────
1194
+ done('fly.conformance.endpoint_probes', 'conformance', 'checkFlyConformance drives ONE real request per claimed endpoint (status set + body predicate, census bijection with the snapshot) and reports clean', 'api', 'common', async () => {
1195
+ const report = await checkFlyConformance();
1196
+ return report.ok && report.endpointsProbed >= 40 && report.violations.length === 0 && report.endpointsChecked === report.endpointsProbed;
1197
+ }),
1198
+ ];
1199
+ // Areas census (TWIN-87): enumerated TOP-DOWN from the first-party OpenAPI's tags — Apps,
1200
+ // Machines, Volumes, Secrets, TLS Certificates, Tokens, Organizations, Platform, Postgres
1201
+ // Clusters — with the Machines tag split into its lifecycle subfamilies (machines / execution /
1202
+ // leases), the secrets tag's typed-key endpoints as secretkeys, the Apps-tag ip_assignments
1203
+ // family surfaced by path, and the twin-lifecycle areas (auth/errors/connector/conformance)
1204
+ // that every pack carries.
1205
+ export const FLY_AREAS = [
1206
+ 'apps', 'machines', 'execution', 'leases', 'volumes', 'secrets', 'secretkeys',
1207
+ 'certificates', 'ip_assignments', 'tokens', 'orgs', 'platform', 'postgres',
1208
+ 'auth', 'errors', 'connector', 'conformance',
1209
+ ];
1210
+ export async function flyCapabilities() {
1211
+ return checkCapabilities('fly', FLY_CAPABILITIES);
1212
+ }