Skip to content

Peer fleet studios (ADR 0027, Horizon 1) — #1341–#1346 - #1353

Merged
jeonghun-jj-lee merged 13 commits into
feature/free-tier-fleetfrom
amico/issue-1340-peer-fleet-adr0027
Sep 21, 2026
Merged

jeonghun-jj-lee merged 13 commits into
feature/free-tier-fleetfrom
amico/issue-1340-peer-fleet-adr0027

Conversation

@jeonghun-jj-lee

@jeonghun-jj-lee jeonghun-jj-lee commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #1340 (Peer fleet studios + compute federation, ADR 0027) and its Horizon-1 sub-issues #1341–#1346, as one PR into feature/free-tier-fleet (not main).

Peer studios are an additive mode alongside the hub/star topology: each machine runs its own engine on its own DB, the loopback amicode_service becomes a multiplexer with a three-way resolver, and the UI can attach/switch between advertised peers. The substrate is designed for Horizon-2 compute federation, which this work declares but does not build.

Walked as an AFK develop-mode campaign (director + per-slice implementer, TDD each; every slice independently director-verified — never on implementer self-report).

Slices (all done + verified)

Additive invariants (the #1346 gate enforces these in CI)

  • amico fleet enroll + the never-fork guard + enroll test suites are byte-unchanged (diff vs the merge-base, not the drifting base tip).
  • No new fleet.json parser; peer reads go through the existing @amicode/schema reader.
  • ≤1 DB writer per machine (adopt-or-spawn adopts, never creates a second writer).
  • The peer path never writes role=client and never installs the guard (attach is proxy-only).

Verification (local, on the merged tree)

  • Full extension suite 4086 passed / 49 skipped, with only the 4 pre-existing environment failures (missing vendor dist, unstaged CLI bins, launchd/systemd fixtures) — zero regressions. (These 4 pass in CI, which builds the binary + stages the CLI.)
  • pnpm --filter amicode run typecheck clean; @amicode/schema 318/318; overlay fleet-manager bun suite 31/31.
  • bash packages/extension/scripts/assert_additive_invariants.sh → exit 0 against the current merge-base.
  • Base drift reconciled: merged origin/feature/free-tier-fleet (a single mechanical barrel-export conflict in schema/src/index.ts, resolved as a union of both added type exports).
  • CI green (run for 443710c3): all 12 checks pass.

Not in this PR

⚠️ One decision for the reviewer

Live proxy/SSE re-target to an attached peer is intentionally UNWIRED (Slices 2 + 4). server.ts is held byte-identical as an invariant, so the D3 resolver isn't in live dispatch(): a switch flips the pointer + resets the SSE cursor, but the live proxy still targets the hub. Every AC is green at the mechanically-checkable unit; the end-to-end "switch → now on the peer's live engine" needs an explicit decision — relax the server.ts invariant for a scoped, reviewed edit, or file a follow-up.

Merge policy

Target: feature/free-tier-fleet. HITL — do not merge without human review. Reviewer must not be the implementer.

Closes #1340
Closes #1341
Closes #1342
Closes #1343
Closes #1344
Closes #1345
Closes #1346

…n (Slice 0)

Design-of-record for #1340. Records the two Horizon-2 federation seams the
Horizon-1 build must not re-plumb:
- peer-trust credential: a new, separately-revocable peer token (ADR §7 / §D10b)
  → satisfies peer_trust_identity_recorded
- SSE event-relay seam: remote-child events tagged with the originating session
  id, merged into that stream (ADR §7 / §D12) → satisfies event_relay_seam_recorded

Additive to the hub/star topology (ADR 0025); the enroll flow and never-fork
guard stay byte-unchanged. Slices #1341-#1346 realize Horizon 1.

Refs #1340
@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: ee1ed456-48cd-4a06-86c0-6ebe72540e0d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ADR 0027 §5/D8 — a peer advertises its already-running engine via the open
capabilities[] axis (ADR 0026). Adds `serving` as the third KNOWN tag
alongside compute/roaming, and placementDescriptor(row): a small, pure helper
a future scheduler reads (reachable? serving?) off a roster row's existing
reach coordinates (sshAlias/transport) and reachability (health) — a
placement-ready fact, not a display-only chip. Deliberately excludes
`headroom`, pinned by an explicit regression test (Horizon-2 only, D8).

AC1 (serving_tag_present_on_roster) + AC2 (placement-ready descriptor).
Route-level proof that GET/POST /amicode/roster carries a serving
advertisement (tag + sshAlias/transport reach coordinates) end to end, and
that placementDescriptor(row) reads it back as reachable/serving with no
headroom field (AC1 + AC2 at the route layer).

Also pins AC4: advertising serving never coerces server_mode (byte-identical
passthrough) and never writes fleet.json (behavioral: a fake $HOME's
topology path stays absent after a serving self-report; structural: a
module-discipline scan proves roster.ts references no fleet.json writer).
No production code change needed here — capabilities[] was already an open
set and the write path already touches only the roster file; these tests
pin that as an enforced regression guard.
ADR 0027 §4/D7 — a NEW, first-class resolvable coordinate for reaching the
directory keeper WITHOUT reading the roster.json the keeper itself hosts
(which would be circular). Its own file (keeper.json, sibling of roster.json
under ~/.amico/ops/fleet/) and its own DI seam (keeperFile / the
AMICO_FLEET_KEEPER_FILE env override), mirroring roster.ts's RosterDeps
precedence exactly. resolveKeeperPointer never imports the roster contract —
pinned both behaviorally (resolution is unaffected by an absent, malformed,
or differently-addressed roster file) and structurally (an import-line scan
asserts no reference to @amicode/schema or the roster module).

Slice 1 ships the coordinate + resolver only; Slice 2 wires the three-way
resolver that consumes it (roster_route_resolves_to_distinct_keeper).

AC3 (a resolvable keeper bootstrap pointer that resolves without reading
roster.json).
#1342)

ADR 0027 §2-3/D6+D3 (Slice 2). The D6 switch-control pointer — the sole
owner of "the currently attached server" — as a NEW module structurally
mirroring Slice 1's keeper_pointer.ts exactly: same DI precedence (injected
path -> $AMICO_FLEET_ATTACHMENT_FILE -> the shared ~/.amico/ops/fleet/
cache path), same Result-never-throw resolve function, same atomic
tmp+rename writer, plus a clearAttachmentPointerFile primitive (Slice 4's
future detach).

The ONE semantic difference from the keeper: an ABSENT attachment pointer
is NOT an error. It is D3's meaningful "empty" default (a fresh standalone
peer that has never attached to anyone) -- resolveAttachmentPointer returns
{ok:true, attached:false}, never a miss. Only a present-but-malformed file
is an honest rejection.

resolveAmicodeTarget is the D3 three-way resolver that generalizes the
EXISTING binary shouldProxyAmicodeToHost: /amicode/fleet* -> local, always;
/amicode/roster(/*) -> the keeper (Slice 1's resolveKeeperPointer),
regardless of attachment; everything else (/amicode/* + the raw engine
plane + SSE) -> the attached server, or local when the pointer is empty or
malformed (the attached branch is never undefined). PURE: takes
already-resolved pointer results, makes no I/O and no network call -- a
routing-TARGET decision ("resolves to"), never an "arrives at" proof
(Slice 3's real transport).

27 tests: pointer resolve/write/clear + env precedence (mirroring
amicode_service_keeper_pointer.test.ts's structure), the
/amicode/fleet/attachment status-response shape, and all 4 of this slice's
acceptance criteria at the pure-resolver level (roster_route_resolves_to_
distinct_keeper, studio_state_resolves_to_attached_server,
honesty_surface_stays_local, empty_attachment_routes_local).

Deliberately NOT wired into server.ts's live dispatch() -- that file's
dispatch()/shouldProxyAmicodeToHost is a byte-unchanged invariant for this
campaign (a later gate asserts an empty diff on the hub/client star-routing
surface). Correct, fully tested, unwired -- matching Slice 1's own
keeper_pointer.ts precedent.
)

Extends registerFleetRoutes with GET /amicode/fleet/attachment, mirroring
the existing GET /amicode/fleet/status route exactly (module-default
resolution, no deps threading -- registerRosterRoutes' simplicity). Placed
under the SAME /amicode/fleet/* prefix as `status`, so it inherits the
EXISTING shouldProxyAmicodeToHost's never-proxied exclusion of that whole
prefix for free -- zero changes to server.ts's dispatch()/
shouldProxyAmicodeToHost (the byte-unchanged hub/client star-routing
invariant this campaign holds).

Extends fleet_client_relay.test.ts per the issue's own Testing Decision
with a NEW nested describe block covering all 4 of this slice's acceptance
criteria against a DISTINCT keeper stub host + attached stub host (two
real HTTP listeners at two real, distinct ephemeral ports -- literal
"distinct addresses", though the resolver never dials either: an afterAll
assertion proves zero requests reached either host across the whole
block, the sharpest available proof that these are routing-TARGET
assertions and never arrival). Plus one live-wire bonus test booting the
EXISTING (unmodified) client relay and hitting the new route over real
HTTP, proving it resolves locally against the hub stub.

roster_route_resolves_to_distinct_keeper, studio_state_resolves_to_
attached_server, honesty_surface_stays_local, empty_attachment_routes_local
-- all green at both the pure-resolver level and this route/fixture level.
…#1343)

ADR 0027 §6-7/D9+D10a (Slice 3). Reaching a peer for real needs a transport
AND a credential; this ships both halves the peer-attach path needs.

- attachment_transport.ts (D9/§6): per-attachment transport bring-up, ON
  DEMAND and parameterized PER TARGET (not the hub's one persistent
  launchd/systemd tunnel). No new provider abstraction — reuses
  fleet_transport.ts's createSshProvider/createTailscaleProvider/
  createDirectProvider/transportForSelection/resolveFleetTransportKind
  exactly as shipped. `roaming`-tagged peers default to tailscale; every
  other peer defaults to ssh, the universal floor a fleet with zero
  Tailscale still attaches over. bringUpSshAttachment spawns a REAL
  `ssh -N -L` child (node:child_process, the same CLI the hub path shells
  out to) and waits for the forward to actually answer end-to-end before
  resolving — an ssh child that exits early rejects with its stderr
  attached, never a fabricated success. The channel is bidirectional-
  capable by construction (a full-duplex TCP/HTTP forward), the load-bearing
  reason Horizon 2's engine<->engine RPC can reuse it without re-plumbing.

- attachment_credential.ts (D10a/§7, H1 half): the per-attachment analogue
  of hub_credential.ts, generalized to a machine_id-keyed store (an attach
  flow can reach many peers, one at a time per D6). Same Authorization
  convention as the hub hop (attachmentUpstreamAuthHeader aliases
  hubUpstreamAuthHeader byte-for-byte) — one auth idiom, hub hop and peer
  hop alike. Named read outcomes (absent/malformed/incomplete), never a
  throw, never a fabricated credential. This is the H1 UI-mint credential
  only (D10a); the Horizon-2 peer-trust token (D10b) is recorded design,
  not built here.

- amicode_service_attachment_transport.test.ts: 21 tests. Pure-logic groups
  (D9 roaming-aware default composed with the existing resolver, per-target
  provider construction, the forward argv shape, the credential store's
  round-trip/isolation/malformed-read behavior, bringUpSshAttachment's
  honest-failure path with a fake exiting ssh child) need no network. A
  fourth group proves attach_over_real_transport == 1 and
  attach_injects_client_credential == 1 against a stub "peer engine" HTTP
  server reached over a REAL `ssh -N -L` loopback forward: an unauthed
  request is refused by the PEER (401), a credentialed one returns 2xx
  carrying a peer-minted identity field the loopback multiplexer could not
  have forged. Gated behind a synchronous readiness probe that tries the
  current identity first (zero mutation) and only falls back to a
  throwaway keypair (atomically appended to ~/.ssh/authorized_keys, then
  removed byte-for-byte in afterAll) when that fails; a machine with no
  loopback sshd honestly `describe.skip`s with a printed reason instead of
  faking a pass.

Needs Slice 2 (#1342, merged). Scope stays within #1343: engine<->engine
RPC and the H2 peer token are out (ADR 0027 §7-8, Horizon 2); the attach
verb + Fleet Manager control are Slice 4.

No changes to fleet_enroll_verb.ts, the fleet guard, server.ts, or any
enroll test suite (additive invariant held).
…#1344)

The attach/detach ACTION (ADR 0027 §3/D5, Slice 4) that finally INVOKES a
switch: attach adds an upstream and sets the Slice-2 D6 pointer from the
matching roster row (the roster is the sole candidate source); detach clears
the pointer + per-attachment credential. A switch (pointer flip to a different
server) resets the multiplexer's per-session SSE cursor via SessionEventResume
.reset(), so the next subscription opens a fresh stream with no ?after= (AC3).

Fixes the open route-registration gap this slice owns: the attach/detach POST
verbs AND the GET /amicode/fleet/attachment pointer route are now registered
UNCONDITIONALLY (registerAttachmentRoutes), reachable on a plain standalone
boot (no opts.fleet) — ADR 0027 requires a standalone peer to attach. They
stay local under the EXISTING shouldProxyAmicodeToHost /amicode/fleet/*
exclusion, with zero server.ts change.

Additive only: server.ts, the never-fork guard, and the enroll verb are
untouched. Tests: the verb (roster candidate source, pointer+credential,
malformed refusal, standalone-boot reachability), the switch cursor reset,
the relay-suite pointer-flip routing, and a p95 latency harness over the REAL
per-attachment SSH transport (attach + switch, peer-identity-proven, p95<=2000ms).
)

The Fleet Manager 'Attach' control (ADR 0027 §3, Slice 4, AC2/AC5): a per-row
Attach/Detach button on the existing device rows that drives the backend
attach/detach verb via amicodePost(server.current, …) — server.current's SAME
loopback origin. The webview stays SINGLE-ORIGIN: a switch is a backend pointer
flip, NOT an SDK-origin swap, so this never touches useServer's add/setActive
(the multi-server switcher the ADR explicitly rejected for the data plane) and
is not a FleetManagerAction (that closed union is VS-Code commands).

On a switch the scoped surfaces (roster + attachment) refetch — a full reload
keyed on the attached machine_id, so no previous studio's cached data leaks
(AC5) — and the plan carries clearEventCursor so a stale lastEventID never
rides to a new origin (AC3, frontend). performAttachControl drives attach AND
detach end-to-end over an injected post (no server-switch capability exists to
it — single-origin by construction).

Pure logic lives in fleet-manager.ts (unit-tested, +12 cases); the tab is thin
wiring. Manifest hashes refreshed for the three edited overlay files only.
Thread an optional placement target (the H2 compute-federation "where"
dimension, ADR-0027 §7 seam 4) through the amicode_session / Task spawn
path, defaulting to "local". Inert in H1: threaded + defaulted, nothing
reads or routes on it.

- session_spawn.ts: SpawnArgs.placement (non-optional, always resolves to
  "local"); parseSpawnArgs accepts placement?: string|null and defaults
  absence/null/empty/whitespace to "local" (trimmed pass-through, no error
  branch). spawnGateKey DELIBERATELY excludes placement in H1 (exclude-with-
  TODO) so a default-local key stays byte-identical to the pre-slice key;
  H2 must add it to the key when placement goes live.
- amicode_tools.ts + amicode_tools_core.ts: sibling placement: schema param
  (identical, parity-guarded) + execute input type. MCP server auto-derives.
- Tests: placement default/null/empty/explicit + gate-key inertness in
  session_spawn.test.ts; pinned arg-key list updated in session_public_floor.
jeonghun-jj-lee added a commit that referenced this pull request Sep 21, 2026
…ter + synthesize canonical-server node (#1363) (#1364)

On a client, the sidebar fleet section showed only itself: readRoster
unconditionally read the machine-local roster cache (empty on a client,
since the roster is host-owned), and nothing rendered the server (never a
roster row). Two sidebar-only changes:

- readRoster is now role-aware: SERVER/standalone read the local cache
  synchronously (unchanged); a CLIENT proxy-reads the host's authoritative
  roster over the existing GET /amicode/roster (forwarded by the local
  service). Any failure is the honest unreachable state, never a fabricated
  or stale list. The seam returns a sync|Promise union so every existing
  synchronous injection is untouched.
- buildFleetSectionModel synthesizes the canonical-server node on a client
  (from fleet.json canonical), mirroring the existing self-row synthesis, so
  the server is visible; shown as down when the host is unreachable.

Sidebar-only: enroll verb, roster schema, and guard are byte-unchanged. The
roster-row enrichment (friendly name + device_type) is deferred to ADR 0027
(#1340 / PR #1353), which owns the roster schema.

Closes #1363
…gle-writer, no client stance (#1346)

Standing mechanical gate (script + vitest + CI wiring) for ADR 0027's peer-studio
additive guarantee. Four invariants:
- AC1 hub_enroll_guard_bytes_changed == 0 — empty diff on the enroll verb, both
  never-fork guard shim copies, and the enroll test suite, measured vs the
  MERGE-BASE (git merge-base HEAD $INVARIANT_BASE_REF), never the base tip, so
  the base's independent drift is not misread as ours and it is rebase-robust.
- AC2 new_fleet_json_parsers == 0 — no new parser of fleet.json outside the one
  @amicode/schema reader (comment- and sibling-pointer-safe scan).
- AC3 max_db_writers_per_machine <= 1 — the attach path is proxy-only (no
  server_lifecycle/adoptOrSpawn; spawns only the ssh forward, never an engine).
- AC4 peer_client_stance_used == 0 — the attach flow writes no fleet.json with
  role=client and installs no guard; exercised behaviorally via the real attach
  verb (writes the attachment pointer, not fleet.json).

The live proxy/SSE re-target remains deliberately unwired (server.ts byte-identical);
proxy-only is consistent with that director-tracked deferral.
…ico/issue-1340-peer-fleet-adr0027

# Conflicts:
#	packages/schema/src/index.ts
@jeonghun-jj-lee jeonghun-jj-lee changed the title Peer fleet studios (ADR 0027, Horizon 1) + deliberate-tooling fix (#1340, #1347) Peer fleet studios (ADR 0027, Horizon 1) — #1341–#1346 Sep 21, 2026
@jeonghun-jj-lee
jeonghun-jj-lee marked this pull request as ready for review September 21, 2026 13:07
The additive-invariants gate diffs the enroll/guard frozen set vs
git merge-base HEAD <base>. The fast job's default shallow (depth-1)
checkout has no common ancestor, so the merge-base is empty and the
gate correctly refuses to pass an UNVERIFIABLE AC1 under
INVARIANT_STRICT=1. Full history lets it verify. (#1346)
@jeonghun-jj-lee
jeonghun-jj-lee merged commit ba0d0cb into feature/free-tier-fleet Sep 21, 2026
12 checks passed
@jeonghun-jj-lee
jeonghun-jj-lee deleted the amico/issue-1340-peer-fleet-adr0027 branch September 21, 2026 13:29
jeonghun-jj-lee added a commit that referenced this pull request Sep 21, 2026
The fleet README and create-a-fleet skill described only the hub/star
topology, predating the peer-studios merge (#1353, ADR 0027). That stale
framing is actively misleading — it reads as though peer support does not
exist. Update both to cover BOTH additive topologies, honestly:

- README: two-topology intro; a Peer studios section (independent
  studios, the serving tag + placementDescriptor, keeper/attachment
  pointers, attach/detach routes, per-attachment transport/credential,
  the Fleet Manager Attach control), the honest caveat that the live
  proxy re-target is unwired (server.ts byte-identical, resolveAmicodeTarget
  has no live caller), and a hub→peer conversion recipe.
- create-a-fleet skill: scope banner (builds hub/star only); a Peer
  studios vs hub/star decision table + when-to-use rule; what ships vs the
  unwired gap vs H2-declared; the guided conversion path; fixed the stale
  fleet-peer-executor bullet.

No code change; docs only.
jeonghun-jj-lee added a commit that referenced this pull request Sep 21, 2026
…ty (#1373)

Proposes retiring the user-facing hub-vs-peer topology split (amends ADR
0027 §D1): one fleet, every device an independent own-engine studio by
default, with 'can others attach to me' expressed as the existing per-device
`serving` capability rather than a topology or role. The never-fork thin
client (ADR 0025) is preserved as an explicit `hosted-only` opt-in, not the
meaning of 'non-hosting device'. Holds the ADR 0023 one-parser, ADR 0005
never-fork, and ADR 0026 closed-server_mode invariants; reinterprets
fleet.json's role values with no schema change. Names the #1353 live
re-target as the single enabler for the unified attach UX. Status: proposed.
jeonghun-jj-lee added a commit that referenced this pull request Sep 21, 2026
…glossary (#1368) (#1370)

* docs(fleet): ADR 0028 + glossary — self-reported device identity in the roster

Pairs with #1368 (gap-3 device identity). Records the shared-derivation
placement (pure classifiers in @amicode/schema, impure shell per node
package), the server self-registration keyed by machine_id=canonical.host
(+ self-row reconciliation), the amicode.device.name/.type override
namespace, and the WSL-abstains detection design. Refined by two
independent /deliberate critics before commit.

* docs(adr): ADR 0029 — one fleet, differentiated by a serving capability (#1373)

Proposes retiring the user-facing hub-vs-peer topology split (amends ADR
0027 §D1): one fleet, every device an independent own-engine studio by
default, with 'can others attach to me' expressed as the existing per-device
`serving` capability rather than a topology or role. The never-fork thin
client (ADR 0025) is preserved as an explicit `hosted-only` opt-in, not the
meaning of 'non-hosting device'. Holds the ADR 0023 one-parser, ADR 0005
never-fork, and ADR 0026 closed-server_mode invariants; reinterprets
fleet.json's role values with no schema change. Names the #1353 live
re-target as the single enabler for the unified attach UX. Status: proposed.
jeonghun-jj-lee added a commit that referenced this pull request Sep 23, 2026
…ter + synthesize canonical-server node (#1363) (#1364)

On a client, the sidebar fleet section showed only itself: readRoster
unconditionally read the machine-local roster cache (empty on a client,
since the roster is host-owned), and nothing rendered the server (never a
roster row). Two sidebar-only changes:

- readRoster is now role-aware: SERVER/standalone read the local cache
  synchronously (unchanged); a CLIENT proxy-reads the host's authoritative
  roster over the existing GET /amicode/roster (forwarded by the local
  service). Any failure is the honest unreachable state, never a fabricated
  or stale list. The seam returns a sync|Promise union so every existing
  synchronous injection is untouched.
- buildFleetSectionModel synthesizes the canonical-server node on a client
  (from fleet.json canonical), mirroring the existing self-row synthesis, so
  the server is visible; shown as down when the host is unreachable.

Sidebar-only: enroll verb, roster schema, and guard are byte-unchanged. The
roster-row enrichment (friendly name + device_type) is deferred to ADR 0027
(#1340 / PR #1353), which owns the roster schema.

Closes #1363
jeonghun-jj-lee added a commit that referenced this pull request Sep 23, 2026
* docs(fleet): ADR 0027 + spec — peer fleet studios & compute federation (Slice 0)

Design-of-record for #1340. Records the two Horizon-2 federation seams the
Horizon-1 build must not re-plumb:
- peer-trust credential: a new, separately-revocable peer token (ADR §7 / §D10b)
  → satisfies peer_trust_identity_recorded
- SSE event-relay seam: remote-child events tagged with the originating session
  id, merged into that stream (ADR §7 / §D12) → satisfies event_relay_seam_recorded

Additive to the hub/star topology (ADR 0025); the enroll flow and never-fork
guard stay byte-unchanged. Slices #1341-#1346 realize Horizon 1.

Refs #1340

* feat(schema): serving capability tag + placementDescriptor (#1341)

ADR 0027 §5/D8 — a peer advertises its already-running engine via the open
capabilities[] axis (ADR 0026). Adds `serving` as the third KNOWN tag
alongside compute/roaming, and placementDescriptor(row): a small, pure helper
a future scheduler reads (reachable? serving?) off a roster row's existing
reach coordinates (sshAlias/transport) and reachability (health) — a
placement-ready fact, not a display-only chip. Deliberately excludes
`headroom`, pinned by an explicit regression test (Horizon-2 only, D8).

AC1 (serving_tag_present_on_roster) + AC2 (placement-ready descriptor).

* test(fleet): pin roster route contract for serving advertisement (#1341)

Route-level proof that GET/POST /amicode/roster carries a serving
advertisement (tag + sshAlias/transport reach coordinates) end to end, and
that placementDescriptor(row) reads it back as reachable/serving with no
headroom field (AC1 + AC2 at the route layer).

Also pins AC4: advertising serving never coerces server_mode (byte-identical
passthrough) and never writes fleet.json (behavioral: a fake $HOME's
topology path stays absent after a serving self-report; structural: a
module-discipline scan proves roster.ts references no fleet.json writer).
No production code change needed here — capabilities[] was already an open
set and the write path already touches only the roster file; these tests
pin that as an enforced regression guard.

* feat(fleet): resolvable keeper bootstrap pointer, non-circular (#1341)

ADR 0027 §4/D7 — a NEW, first-class resolvable coordinate for reaching the
directory keeper WITHOUT reading the roster.json the keeper itself hosts
(which would be circular). Its own file (keeper.json, sibling of roster.json
under ~/.amico/ops/fleet/) and its own DI seam (keeperFile / the
AMICO_FLEET_KEEPER_FILE env override), mirroring roster.ts's RosterDeps
precedence exactly. resolveKeeperPointer never imports the roster contract —
pinned both behaviorally (resolution is unaffected by an absent, malformed,
or differently-addressed roster file) and structurally (an import-line scan
asserts no reference to @amicode/schema or the roster module).

Slice 1 ships the coordinate + resolver only; Slice 2 wires the three-way
resolver that consumes it (roster_route_resolves_to_distinct_keeper).

AC3 (a resolvable keeper bootstrap pointer that resolves without reading
roster.json).

* feat(fleet): switch-control attachment pointer + D3 three-way resolver (#1342)

ADR 0027 §2-3/D6+D3 (Slice 2). The D6 switch-control pointer — the sole
owner of "the currently attached server" — as a NEW module structurally
mirroring Slice 1's keeper_pointer.ts exactly: same DI precedence (injected
path -> $AMICO_FLEET_ATTACHMENT_FILE -> the shared ~/.amico/ops/fleet/
cache path), same Result-never-throw resolve function, same atomic
tmp+rename writer, plus a clearAttachmentPointerFile primitive (Slice 4's
future detach).

The ONE semantic difference from the keeper: an ABSENT attachment pointer
is NOT an error. It is D3's meaningful "empty" default (a fresh standalone
peer that has never attached to anyone) -- resolveAttachmentPointer returns
{ok:true, attached:false}, never a miss. Only a present-but-malformed file
is an honest rejection.

resolveAmicodeTarget is the D3 three-way resolver that generalizes the
EXISTING binary shouldProxyAmicodeToHost: /amicode/fleet* -> local, always;
/amicode/roster(/*) -> the keeper (Slice 1's resolveKeeperPointer),
regardless of attachment; everything else (/amicode/* + the raw engine
plane + SSE) -> the attached server, or local when the pointer is empty or
malformed (the attached branch is never undefined). PURE: takes
already-resolved pointer results, makes no I/O and no network call -- a
routing-TARGET decision ("resolves to"), never an "arrives at" proof
(Slice 3's real transport).

27 tests: pointer resolve/write/clear + env precedence (mirroring
amicode_service_keeper_pointer.test.ts's structure), the
/amicode/fleet/attachment status-response shape, and all 4 of this slice's
acceptance criteria at the pure-resolver level (roster_route_resolves_to_
distinct_keeper, studio_state_resolves_to_attached_server,
honesty_surface_stays_local, empty_attachment_routes_local).

Deliberately NOT wired into server.ts's live dispatch() -- that file's
dispatch()/shouldProxyAmicodeToHost is a byte-unchanged invariant for this
campaign (a later gate asserts an empty diff on the hub/client star-routing
surface). Correct, fully tested, unwired -- matching Slice 1's own
keeper_pointer.ts precedent.

* feat(fleet): wire the attachment pointer's local honesty endpoint (#1342)

Extends registerFleetRoutes with GET /amicode/fleet/attachment, mirroring
the existing GET /amicode/fleet/status route exactly (module-default
resolution, no deps threading -- registerRosterRoutes' simplicity). Placed
under the SAME /amicode/fleet/* prefix as `status`, so it inherits the
EXISTING shouldProxyAmicodeToHost's never-proxied exclusion of that whole
prefix for free -- zero changes to server.ts's dispatch()/
shouldProxyAmicodeToHost (the byte-unchanged hub/client star-routing
invariant this campaign holds).

Extends fleet_client_relay.test.ts per the issue's own Testing Decision
with a NEW nested describe block covering all 4 of this slice's acceptance
criteria against a DISTINCT keeper stub host + attached stub host (two
real HTTP listeners at two real, distinct ephemeral ports -- literal
"distinct addresses", though the resolver never dials either: an afterAll
assertion proves zero requests reached either host across the whole
block, the sharpest available proof that these are routing-TARGET
assertions and never arrival). Plus one live-wire bonus test booting the
EXISTING (unmodified) client relay and hitting the new route over real
HTTP, proving it resolves locally against the hub stub.

roster_route_resolves_to_distinct_keeper, studio_state_resolves_to_
attached_server, honesty_surface_stays_local, empty_attachment_routes_local
-- all green at both the pure-resolver level and this route/fixture level.

* feat(fleet): per-attachment transport + credential store, SSH-default (#1343)

ADR 0027 §6-7/D9+D10a (Slice 3). Reaching a peer for real needs a transport
AND a credential; this ships both halves the peer-attach path needs.

- attachment_transport.ts (D9/§6): per-attachment transport bring-up, ON
  DEMAND and parameterized PER TARGET (not the hub's one persistent
  launchd/systemd tunnel). No new provider abstraction — reuses
  fleet_transport.ts's createSshProvider/createTailscaleProvider/
  createDirectProvider/transportForSelection/resolveFleetTransportKind
  exactly as shipped. `roaming`-tagged peers default to tailscale; every
  other peer defaults to ssh, the universal floor a fleet with zero
  Tailscale still attaches over. bringUpSshAttachment spawns a REAL
  `ssh -N -L` child (node:child_process, the same CLI the hub path shells
  out to) and waits for the forward to actually answer end-to-end before
  resolving — an ssh child that exits early rejects with its stderr
  attached, never a fabricated success. The channel is bidirectional-
  capable by construction (a full-duplex TCP/HTTP forward), the load-bearing
  reason Horizon 2's engine<->engine RPC can reuse it without re-plumbing.

- attachment_credential.ts (D10a/§7, H1 half): the per-attachment analogue
  of hub_credential.ts, generalized to a machine_id-keyed store (an attach
  flow can reach many peers, one at a time per D6). Same Authorization
  convention as the hub hop (attachmentUpstreamAuthHeader aliases
  hubUpstreamAuthHeader byte-for-byte) — one auth idiom, hub hop and peer
  hop alike. Named read outcomes (absent/malformed/incomplete), never a
  throw, never a fabricated credential. This is the H1 UI-mint credential
  only (D10a); the Horizon-2 peer-trust token (D10b) is recorded design,
  not built here.

- amicode_service_attachment_transport.test.ts: 21 tests. Pure-logic groups
  (D9 roaming-aware default composed with the existing resolver, per-target
  provider construction, the forward argv shape, the credential store's
  round-trip/isolation/malformed-read behavior, bringUpSshAttachment's
  honest-failure path with a fake exiting ssh child) need no network. A
  fourth group proves attach_over_real_transport == 1 and
  attach_injects_client_credential == 1 against a stub "peer engine" HTTP
  server reached over a REAL `ssh -N -L` loopback forward: an unauthed
  request is refused by the PEER (401), a credentialed one returns 2xx
  carrying a peer-minted identity field the loopback multiplexer could not
  have forged. Gated behind a synchronous readiness probe that tries the
  current identity first (zero mutation) and only falls back to a
  throwaway keypair (atomically appended to ~/.ssh/authorized_keys, then
  removed byte-for-byte in afterAll) when that fails; a machine with no
  loopback sshd honestly `describe.skip`s with a printed reason instead of
  faking a pass.

Needs Slice 2 (#1342, merged). Scope stays within #1343: engine<->engine
RPC and the H2 peer token are out (ADR 0027 §7-8, Horizon 2); the attach
verb + Fleet Manager control are Slice 4.

No changes to fleet_enroll_verb.ts, the fleet guard, server.ts, or any
enroll test suite (additive invariant held).

* feat(fleet): attach/detach verb + always-on routes + SSE cursor reset (#1344)

The attach/detach ACTION (ADR 0027 §3/D5, Slice 4) that finally INVOKES a
switch: attach adds an upstream and sets the Slice-2 D6 pointer from the
matching roster row (the roster is the sole candidate source); detach clears
the pointer + per-attachment credential. A switch (pointer flip to a different
server) resets the multiplexer's per-session SSE cursor via SessionEventResume
.reset(), so the next subscription opens a fresh stream with no ?after= (AC3).

Fixes the open route-registration gap this slice owns: the attach/detach POST
verbs AND the GET /amicode/fleet/attachment pointer route are now registered
UNCONDITIONALLY (registerAttachmentRoutes), reachable on a plain standalone
boot (no opts.fleet) — ADR 0027 requires a standalone peer to attach. They
stay local under the EXISTING shouldProxyAmicodeToHost /amicode/fleet/*
exclusion, with zero server.ts change.

Additive only: server.ts, the never-fork guard, and the enroll verb are
untouched. Tests: the verb (roster candidate source, pointer+credential,
malformed refusal, standalone-boot reachability), the switch cursor reset,
the relay-suite pointer-flip routing, and a p95 latency harness over the REAL
per-attachment SSH transport (attach + switch, peer-identity-proven, p95<=2000ms).

* feat(fleet-manager): per-row Attach/Detach control, single-origin (#1344)

The Fleet Manager 'Attach' control (ADR 0027 §3, Slice 4, AC2/AC5): a per-row
Attach/Detach button on the existing device rows that drives the backend
attach/detach verb via amicodePost(server.current, …) — server.current's SAME
loopback origin. The webview stays SINGLE-ORIGIN: a switch is a backend pointer
flip, NOT an SDK-origin swap, so this never touches useServer's add/setActive
(the multi-server switcher the ADR explicitly rejected for the data plane) and
is not a FleetManagerAction (that closed union is VS-Code commands).

On a switch the scoped surfaces (roster + attachment) refetch — a full reload
keyed on the attached machine_id, so no previous studio's cached data leaks
(AC5) — and the plan carries clearEventCursor so a stale lastEventID never
rides to a new origin (AC3, frontend). performAttachControl drives attach AND
detach end-to-end over an injected post (no server-switch capability exists to
it — single-origin by construction).

Pure logic lives in fleet-manager.ts (unit-tested, +12 cases); the tab is thin
wiring. Manifest hashes refreshed for the three edited overlay files only.

* feat(spawn): optional placement target seam, default "local" (#1345)

Thread an optional placement target (the H2 compute-federation "where"
dimension, ADR-0027 §7 seam 4) through the amicode_session / Task spawn
path, defaulting to "local". Inert in H1: threaded + defaulted, nothing
reads or routes on it.

- session_spawn.ts: SpawnArgs.placement (non-optional, always resolves to
  "local"); parseSpawnArgs accepts placement?: string|null and defaults
  absence/null/empty/whitespace to "local" (trimmed pass-through, no error
  branch). spawnGateKey DELIBERATELY excludes placement in H1 (exclude-with-
  TODO) so a default-local key stays byte-identical to the pre-slice key;
  H2 must add it to the key when placement goes live.
- amicode_tools.ts + amicode_tools_core.ts: sibling placement: schema param
  (identical, parity-guarded) + execute input type. MCP server auto-derives.
- Tests: placement default/null/empty/explicit + gate-key inertness in
  session_spawn.test.ts; pinned arg-key list updated in session_public_floor.

* additive-invariants gate: hub/enroll/guard unchanged, one parser, single-writer, no client stance (#1346)

Standing mechanical gate (script + vitest + CI wiring) for ADR 0027's peer-studio
additive guarantee. Four invariants:
- AC1 hub_enroll_guard_bytes_changed == 0 — empty diff on the enroll verb, both
  never-fork guard shim copies, and the enroll test suite, measured vs the
  MERGE-BASE (git merge-base HEAD $INVARIANT_BASE_REF), never the base tip, so
  the base's independent drift is not misread as ours and it is rebase-robust.
- AC2 new_fleet_json_parsers == 0 — no new parser of fleet.json outside the one
  @amicode/schema reader (comment- and sibling-pointer-safe scan).
- AC3 max_db_writers_per_machine <= 1 — the attach path is proxy-only (no
  server_lifecycle/adoptOrSpawn; spawns only the ssh forward, never an engine).
- AC4 peer_client_stance_used == 0 — the attach flow writes no fleet.json with
  role=client and installs no guard; exercised behaviorally via the real attach
  verb (writes the attachment pointer, not fleet.json).

The live proxy/SSE re-target remains deliberately unwired (server.ts byte-identical);
proxy-only is consistent with that director-tracked deferral.

* ci(fast): fetch-depth 0 so the #1346 gate can compute the merge-base

The additive-invariants gate diffs the enroll/guard frozen set vs
git merge-base HEAD <base>. The fast job's default shallow (depth-1)
checkout has no common ancestor, so the merge-base is empty and the
gate correctly refuses to pass an UNVERIFIABLE AC1 under
INVARIANT_STRICT=1. Full history lets it verify. (#1346)
jeonghun-jj-lee added a commit that referenced this pull request Sep 23, 2026
The fleet README and create-a-fleet skill described only the hub/star
topology, predating the peer-studios merge (#1353, ADR 0027). That stale
framing is actively misleading — it reads as though peer support does not
exist. Update both to cover BOTH additive topologies, honestly:

- README: two-topology intro; a Peer studios section (independent
  studios, the serving tag + placementDescriptor, keeper/attachment
  pointers, attach/detach routes, per-attachment transport/credential,
  the Fleet Manager Attach control), the honest caveat that the live
  proxy re-target is unwired (server.ts byte-identical, resolveAmicodeTarget
  has no live caller), and a hub→peer conversion recipe.
- create-a-fleet skill: scope banner (builds hub/star only); a Peer
  studios vs hub/star decision table + when-to-use rule; what ships vs the
  unwired gap vs H2-declared; the guided conversion path; fixed the stale
  fleet-peer-executor bullet.

No code change; docs only.
jeonghun-jj-lee added a commit that referenced this pull request Sep 23, 2026
…glossary (#1368) (#1370)

* docs(fleet): ADR 0028 + glossary — self-reported device identity in the roster

Pairs with #1368 (gap-3 device identity). Records the shared-derivation
placement (pure classifiers in @amicode/schema, impure shell per node
package), the server self-registration keyed by machine_id=canonical.host
(+ self-row reconciliation), the amicode.device.name/.type override
namespace, and the WSL-abstains detection design. Refined by two
independent /deliberate critics before commit.

* docs(adr): ADR 0029 — one fleet, differentiated by a serving capability (#1373)

Proposes retiring the user-facing hub-vs-peer topology split (amends ADR
0027 §D1): one fleet, every device an independent own-engine studio by
default, with 'can others attach to me' expressed as the existing per-device
`serving` capability rather than a topology or role. The never-fork thin
client (ADR 0025) is preserved as an explicit `hosted-only` opt-in, not the
meaning of 'non-hosting device'. Holds the ADR 0023 one-parser, ADR 0005
never-fork, and ADR 0026 closed-server_mode invariants; reinterprets
fleet.json's role values with no schema change. Names the #1353 live
re-target as the single enabler for the unified attach UX. Status: proposed.
jeonghun-jj-lee added a commit that referenced this pull request Sep 27, 2026
…1590) (#1591)

Re-applies the 0ebc977 convention: the two specs re-introduced by #1353/#1142
belong in the knowledge vault, not the source tree. No code/CI references them;
content preserved in the vault. Deletes the now-empty config/ folder.

Co-authored-by: amico-director <director@amico.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant