Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLI
Assigneesstack72

Relationships

#966 Worker fleets phase 2 PR1: fleet tokens (--max-enrollments, bindings, worker naming)

Opened by stack72 · 7/4/2026· Shipped 7/4/2026

Summary

First PR of worker fleets phase 2 (fleet provisioning). Changes the enrollment token model from one-token-one-machine to one-token-N-machines, so a launch template or replica set needs exactly one secret.

Today, each worker needs its own enrollment token. A fleet of 8 workers needs 8 tokens minted and distributed individually. After this PR, one fleet token with `--max-enrollments 8` (or `unlimited`) enrolls all of them, and each gets a distinct identity in the pool.

Design reference: `worker-fleets.md` at the repo root, section "Fleet tokens". This issue is self-contained for implementation.

Changes

1. Enrollment token model: maxEnrollments + bindings list

File: `src/domain/models/worker/enrollment_token_model.ts`

Schema changes to `EnrollmentTokenSchema` (lines 59-72):

Replace: ```typescript boundMachineId: z.string().optional(), enrolledAt: z.string().datetime().optional(), ```

With: ```typescript maxEnrollments: z.union([z.number().int().positive(), z.literal("unlimited")]).default(1), bindings: z.array(z.object({ machineId: z.string(), enrolledAt: z.string().datetime(), })).default([]), ```

Default `1` preserves the existing single-machine behavior — every existing token works identically. The `bindings` list replaces the singular `boundMachineId`.

Migration: Add a Zod `preprocess` or `transform` that reads a legacy record's `boundMachineId` as a one-element `bindings` list: ```typescript // On read: if legacy boundMachineId exists and bindings is empty, // treat it as bindings: [{ machineId: boundMachineId, enrolledAt }] ``` This is a read-time migration — no data rewrite needed. The next write (any state transition) persists the new shape.

`mint` method changes (lines 97-136):

Accept a new optional input `maxEnrollments`: ```typescript const MintArgsSchema = z.object({ durationMs: z.number().positive(), vaultName: z.string(), maxEnrollments: z.union([z.number().int().positive(), z.literal("unlimited")]).default(1), }); ```

Write `maxEnrollments` into the initial record. The record starts with an empty `bindings` list (no machine has enrolled yet).

`redeem` method changes (lines 138-210):

Current logic:

  • `unused` + no binding → bind machineId, transition to `enrolled`
  • `enrolled` + same machineId → re-auth (no write)
  • `enrolled` + different machineId → reject

New logic:

  • Known machineId (exists in `bindings`) → re-auth (no write), regardless of how many other machines are bound. State stays `enrolled`.
  • New machineId + allowance not exhausted → append `{machineId, enrolledAt}` to bindings, transition to `enrolled` if still `unused`. State once `enrolled` stays `enrolled` for subsequent bindings.
  • New machineId + allowance exhausted → reject with "enrollment allowance exhausted" error
  • Revoked/expired → reject (unchanged)

Allowance check: `bindings.length < maxEnrollments` (or skip if `"unlimited"`).

`revoke` method: unchanged — applies to the whole token (all bindings disconnected).

`expire` method: unchanged — applies to the whole token.

2. Worker naming for fleet tokens

File: `src/serve/worker_gateway.ts`

In `#handleEnroll` (lines 326-466), after successful redeem:

  • If `maxEnrollments === 1`: name = token name (unchanged — the degenerate single-machine case)
  • If `maxEnrollments > 1` or `"unlimited"`: name = `-` where suffix is a short stable hash of the machineId (first 4 chars of hex SHA-256). Stable across reconnects of the same machine.

Auto-inject fleet label: At enrollment, inject `fleet=` into the worker's label set. Worker-supplied labels win on conflict (if the worker already has a `fleet` label, keep it, but log a warning).

Reading maxEnrollments: The gateway needs to read the token's `maxEnrollments` after redeem to decide the naming strategy. The `redeem` method already reads the token record — either return `maxEnrollments` in the redeem result, or read it from the token data after redeem (same pattern as `#defaultReadTokenExpiresAt` at lines 618-649).

3. `--max-enrollments` on `worker token create`

File: `src/cli/commands/worker_token_create.ts`

Add flag: ``` --max-enrollments <n:string> Maximum machines this token can enroll (positive integer or "unlimited"). Default: 1 ```

Parse as integer or the literal string `"unlimited"`. Pass to the libswamp `workerTokenCreate` operation which passes it through to the `mint` method.

File: `src/libswamp/worker/token_create.ts`

Add `maxEnrollments` to `WorkerTokenCreateInput` (lines 54-59) and thread it through to the `runMint` call.

4. `worker token list` output changes

File: `src/presentation/output/worker_output.ts`

Update the token list table (lines 140-151):

  • Change the `BOUND MACHINE` column to `ENROLLMENTS`
  • Show ` / ` (e.g., `3 / 50`, `1 / 1`, `8 / unlimited`)

For JSON output, add `maxEnrollments` and `bindings` fields to the token list items.

File: `src/libswamp/worker/list.ts`

Update `WorkerTokenListItem` to include `maxEnrollments` and `bindingCount` (or the full `bindings` array for JSON mode).

5. Stable hash helper

Either inline in the gateway or add a small helper: ```typescript async function fleetMemberSuffix(machineId: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(machineId), ); return Array.from(new Uint8Array(digest)) .slice(0, 2) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } ```

4 hex chars → 65,536 possible suffixes. Collision probability is negligible for fleet sizes under ~200 (birthday paradox threshold). If a collision occurs, the second machine's enrollment fails with "already_connected" — the operator mints a second token. This is an edge case, not a failure mode.

Invariants to preserve

  1. Sole-writer rule. Token transitions (redeem with binding append) are serialized through `#recordTransition` in the gateway. Two machines racing the last allowance slot cannot both win.
  2. Existing tokens must keep working. `maxEnrollments` defaults to `1`, and the legacy `boundMachineId` migration is read-time. No data rewrite, no breaking change.
  3. Reconnect-for-lifetime semantics. A bound machine in the `bindings` list can re-authenticate with `{token, machineId}` until the token's hard deadline, surviving restarts and socket drops. Each binding is independent.
  4. Protocol discipline. If the enrollment result changes shape (e.g., returning `maxEnrollments`), bump `REMOTE_PROTOCOL_VERSION`. If only the token model's internal schema changes, no bump needed.
  5. Workers stay repo-less. The worker doesn't know or care about fleet tokens — it presents the same `.` credential. Fleet behavior is entirely orchestrator-side.

Tests required

Unit tests (`enrollment_token_model_test.ts`):

  • `maxEnrollments: 1` behaves identically to today's single-machine token
  • `maxEnrollments: 3`: first three machines bind successfully, fourth is rejected
  • `maxEnrollments: "unlimited"`: N machines bind without rejection
  • Known machineId re-auths without a write (no new binding appended)
  • Revoke disconnects all bindings (token-level, not per-binding)
  • Legacy record migration: `boundMachineId` without `bindings` field reads as one-element list

Unit tests (`worker_gateway_test.ts`):

  • Fleet naming: `maxEnrollments > 1` → name is `-`, suffix is stable across reconnects
  • Single-machine naming: `maxEnrollments === 1` → name is token name (unchanged)
  • Auto-injected `fleet=` label appears on enrolled worker
  • Worker-supplied `fleet` label wins over auto-injected one

Integration test (`integration/remote_execution_test.ts`):

  • Two workers enrolled on one fleet token: distinct pool identities, both dispatchable

CLI tests:

  • `--max-enrollments 5` passes through to mint
  • `--max-enrollments unlimited` passes through correctly
  • Token list shows enrollments used/allowance

Acceptance criteria

  1. `swamp worker token create pool --max-enrollments 3` + three `swamp worker connect` (same token) yields three distinct named pool members
  2. A fourth machine is rejected with "enrollment allowance exhausted"
  3. `swamp worker token list` shows `3 / 3` in the enrollments column
  4. `swamp worker list` shows three workers with distinct names (`pool-`, `pool-`, `pool-`) and `fleet=pool` label
  5. Existing single-machine tokens work identically (no `--max-enrollments` flag = default 1)
  6. A legacy token record (with `boundMachineId`, without `bindings`) is read and used correctly after upgrade
  7. Revoke disconnects all three workers
  8. All existing tests pass
  9. `deno check`, `deno lint`, `deno fmt`, `deno run test`, `deno run compile` all pass

Repo conventions

  • New `.ts` files need the AGPLv3 header — run `deno run license-headers`
  • TypeScript strict, no `any`, named exports only
  • CLI commands and renderers import libswamp only via `src/libswamp/mod.ts`
  • Every command supports both `log` and `json` output modes
  • Unit tests live next to source: `foo.ts` → `foo_test.ts`
  • Use the `ddd` skill when shaping domain changes
  • Use the `github-pr` skill for commits/PRs
  • Keep blast radius small — no adjacent refactors
  • Update `worker-fleets.md` when implementation diverges from the plan
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 8 MOREREVIEW+ 4 MOREPR_MERGED+ 1 MORENOTIFICATION_SKIPPED

Shipped

7/4/2026, 9:15:09 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack727/4/2026, 1:30:18 PM

Sign in to post a ripple.