Skip to content

fakerelay: serve adoptable rows and the adopt door for eve #541

Closed

Description

@barelyworkingcode

User story

As the owner, I want eve's Adopt button tested against the fake relay, so that eve's adopt flow is proven before it meets the real relay.

Acceptance criteria

  • fakerelay serves adoptable and adoptReason on outside rows, as relay#527 pins them.
  • fakerelay answers POST /api/sessions/adopt with the success shape and the refusal codes relay#527 pins.
  • The fakerelay contract test covers the door and the new row fields.

What I'd notice

Nothing.

Context

Found while working #527 (planner, open question 2). eve#597 tests against fakerelay, which serves neither field nor the door. relay#524 carried its own fakerelay part the same way. It should land before eve#597.

Tasks

  • Planner: write the ## Contract.
  • fakerelay world and route.
  • Contract test.

Contract

Planner contract, 2026-10-20. Base epic/525-session-adoption (relay#527 merged there as relay#547, head 552a068); head feat/541-fakerelay-adopt. Child of epic relay#525. One PR into the epic branch. It merges in any order with the other children; eve#597 pins the commit it lands as.

Story check: passes. Nothing user-visible: fakerelay is a test tool, area no-test in docs/FEATURES.md, so no row moves and the PR says why. No threat-model promise moves. Wire shapes come from relay#527's contract, docs/routes.md ("POST /api/sessions/adopt") and the merged code at 552a068.

Merged code against relay#527's contract

The code is the truth. fakerelay follows these:

  1. Session-host readiness (502 unavailable) is checked straight after audit readiness, before the row. The contract put it after managed.
  2. A selector that names an ended row answers 409 ended. A claudeSessionId that two live console rows hold answers 409 ambiguous with its own message.
  3. model in the body is checked: at most 200 characters, valid UTF-8, no control characters, else 400 invalid_body. Data after the JSON value is 400 invalid_body.
  4. A parse refusal writes session.adopt with no keys. Every later answer writes it with all five keys.
  5. A live row with no conversation id carries no claudeSessionId (omitempty).

Decisions

  1. Rows. Every outside row carries adoptable. A row that is not adoptable carries adoptReason. A live row may carry claudeSessionId, as relay#527 decision 2 serves it. This lifts relay#524's fakerelay rule "a live row with claude_session_id is refused" (question 1).
  2. Absent keys fail closed. A fixture row with no adoptable reads adoptable:false. Its reason is the first rule that applies, in relay's order: ended (ended row), host_unsupported (host_id set), no_project (project_id null), else not_registered. Existing fixtures stay valid (question 2).
  3. The outcome comes from the fixture. fakerelay runs no process, sends no signal and reads no registry or transcript file. The row's adopt object says whether the idle check passes, which model family the transcript names, and which late refusal happens. Everything relay judges from state fakerelay holds is judged from that state: the body, the row, the project, managed, the launch authorization.
  4. Success mirrors relay. The outside row leaves the snapshot and a managed session takes its place, as relay's next probe shows no outside row once relay holds the conversation.
  5. The recovery message is sent. After an interrupted adopt, fakerelay sends relay's recovery text as a turn with origin relay-recovery, before it answers (question 3).
  6. Audit rows are not faked. fakerelay writes no session_adopt, session_interrupt, session_message or session_launch row, as it writes none for other launches today.

World keys (outside_sessions[], world file and PUT /v1/outside-sessions)

Key Shape Rule
adoptable bool, default false true needs: state not ended; host_id null; project_id set; id matches ^outside:[0-9a-f]{32}$; claude_session_id a UUID (^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$); no adopt_reason
adopt_reason one of ended, host_unsupported, no_project, not_registered, invalid_id, start_mismatch, not_interactive, not_command_line, ambiguous only when adoptable is false. Where decision 2's first three rules apply it must equal that code; otherwise one of the last six. Absent: decision 2's default
claude_session_id relay#524's stem pattern ^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$ now valid on live rows too; served as claudeSessionId on list, detail and the scoped WebSocket snapshot
adopt {busy?, model_family?, refuse?}, unknown keys refused only with adoptable: true
adopt.busy registry_busy, status_stale, open_tool_call, child_process the idle check's veto. Absent: idle
adopt.model_family haiku, sonnet, opus the family relay derives from the transcript. Absent: none derivable
adopt.refuse audit_unavailable, unavailable, adopt_in_progress, changed, stop_failed, launch_failed the refusal relay would reach from process or host state. Absent: the adopt succeeds

A violation is relay#524's existing answer: PUT 400 invalid outside fixture; a world file fails the load at outside_sessions[i].<key>. A test changes an outcome mid-run with PUT /v1/outside-sessions. No new ctl verb.

The door: POST /api/sessions/adopt

Class execute, socket only (absent from the TCP mux). With X-Relay-Scope: chief-of-staff it answers 403 Forbidden (the scope table's "any other route"). Served by fakerelay itself, like POST /api/sessions. Body and answers are relay's, word for word. Errors are {"error":"<code>","message":"<text>"} (coded with message).

Judging order (first match answers; nothing changes before step 10):

  1. Body, at most 4096 bytes, one JSON value, unknown fields refused.
    • over the cap: 413 body_too_large request body is larger than 4 KiB
    • not JSON, unknown field, wrong type, trailing data: 400 invalid_body body must be JSON {"outsideId" or "claudeSessionId", "interrupt", "model"}
    • both selectors or neither: 400 invalid_body send outsideId or claudeSessionId, not both and not neither
    • outsideId not ^outside:[0-9a-f]{32}$: 400 invalid_body outsideId is not an outside session id
    • claudeSessionId not a UUID: 400 invalid_body claudeSessionId is not a UUID
    • bad model (merged-code note 3): 400 invalid_body model is not a model name
  2. Resolve the row.
    • outsideId: the row with that id. None: 404 outside_session_not_found no live outside session matches. An ended row: 409 ended the session has ended; there is no process to adopt.
    • claudeSessionId: the live rows with host_id null and that claude_session_id. None: 409 ended (same text) when any ended row carries the id, else the 404. Two or more: 409 ambiguous more than one live process holds this conversation.
  3. A row with host_id set: 409 host_unsupported sessions on an SSH host cannot be adopted. A row not adoptable: 409 <adoptReason> the session cannot be adopted: <adoptReason>.
  4. adopt.refuse of the early kind:
    • audit_unavailable: 503 auditing is off; no session can be adopted
    • unavailable: 502 the session host is not available
    • adopt_in_progress: 409 an adopt of this conversation is already running
    • changed: 409 the process or its registry entry changed
  5. project_id names no world project, or a host or remote one: 409 no_project the session is not in a registered local project.
  6. A fakerelay session made by an earlier adopt holds the conversation id: 409 managed relay already holds this conversation.
  7. adopt.busy set and interrupt false: 409 busy the session is not idle (<veto>); ask to interrupt to take it over anyway.
  8. Model: the body's model, else adopt.model_family. Neither: 409 model_unknown the model of the session is unknown; send model.
  9. Launch authorization: fakerelay's authorize(project, row.directory, model, "claude"). A refusal answers its status, with the code renamed as the Chief of Staff start renames it (cosCodes: project_not_available, directory_outside_project; model_not_allowed unchanged) and its text.
  10. adopt.refuse of the late kind:
    • stop_failed: 502 the process did not exit after SIGKILL; nothing was launched. The row stays.
    • launch_failed: 502 the process ended but relay could not start the session; the conversation is intact and can be resumed. The row becomes ended: state:"ended", ended_at the instance clock's now (RFC 3339 UTC, seconds), adopt dropped, claude_session_id kept; it then reads adoptable:false, adoptReason:"ended".
  11. Success, in this order:
    • Remove the row (fails if it is already gone: the step-2 404). revision goes up by one, every /ws/outside-sessions reader gets the replacement snapshot, and outside_sessions.changed is written, as PUT /v1/outside-sessions does today.
    • Launch a session as POST /api/sessions would with {"projectId", "directory": <row directory>, "name": <row name>, "model": <step 8>, "settings": {"headless":true,"agent":true}}, kind claude, no origin. It is listed, headless:true, attended (attention.state idle), and can be dropped into. It records the conversation id for step 6.
    • interrupted is true when adopt.busy was set (an idle row with interrupt:true is not interrupted). When interrupted, send the recovery text through the turn path the Chief of Staff message uses (beginTurn before the answer, runTurn after), origin relay-recovery. recoverySent is true when beginTurn accepted it.
    • Answer 201 {"sessionId","claudeSessionId","interrupted","recoverySent"}, exactly these four keys.

Recovery text: relay's adopt.RecoveryText (decision 10 of relay#527), filled from the row's transcript. Prompt: the newest user entry's text. Open call: the newest tool entry with kind:"call" that no later kind:"result" entry follows. Its name is name, its id is e<index> (the transcript route's entry id), its input is the entry's text compacted as JSON, or null when the text is not JSON. Each value is joined to one line and cut to 300, 128 and 500 characters, the last one … when cut. With no open call, lines 3 and 4 are relay's no-call lines.

Event session.adopt, one per request, written before the answer: outside_id, claude_session_id, project_id, session_id, interrupted (merged-code note 4). Outcome ok; a refusal's reason is its code, outcome denied below 500 and error at or above.

Interfaces

// fakerelay/internal/server/api.go
// OutsideRows moves outside fixture rows. Each call bumps the revision, pushes the
// replacement snapshot to every /ws/outside-sessions reader and writes
// outside_sessions.changed before it returns. False: no row has that id.
type OutsideRows interface {
	RemoveOutside(ctx context.Context, id string) bool
	EndOutside(ctx context.Context, id string, endedAt time.Time) bool
}
// Deps gains: Outside OutsideRows // set from the api domain's registration, before sessions registers

// fakerelay/internal/api/register.go
func Register(r server.Registrar, d server.Deps) (server.OutsideRows, error)

// fakerelay/internal/world/types.go, OutsideFixture gains:
Adoptable   bool          `json:"adoptable,omitempty"`
AdoptReason string        `json:"adopt_reason,omitempty"`
Adopt       *OutsideAdopt `json:"adopt,omitempty"`
type OutsideAdopt struct {
	Busy        string `json:"busy,omitempty"`
	ModelFamily string `json:"model_family,omitempty"`
	Refuse      string `json:"refuse,omitempty"`
}
// world/validate.go: func OutsideAdoptReason(o OutsideFixture) string // the served adoptReason, "" when adoptable

// fakerelay/internal/sessions/adopt.go (new)
func (s *svc) adopt(w http.ResponseWriter, r *http.Request) // registered: r.Route(server.ClassExecute, "POST /api/sessions/adopt", s.adopt)

outsideRow serves adoptable always, adoptReason from OutsideAdoptReason, and claudeSessionId on any row that has it.

Paths

  • Simple: none in relay. eve#597's Adopt and Interrupt-and-adopt buttons are the client; an eve test seeds one adoptable row and taps.
  • Power: the world file and PUT /v1/outside-sessions: a test picks each outcome with adopt.busy, adopt.model_family and adopt.refuse. No ctl verb; the fixture replace already exists.

File ownership

Task Owns
T1 dev fakerelay/internal/sessions/adopt.go (new), fakerelay/internal/sessions/register.go (the route line), fakerelay/internal/sessions/session.go (the conversation-id field on a session), fakerelay/internal/api/outside.go, fakerelay/internal/api/register.go, fakerelay/internal/server/api.go, fakerelay/internal/surface/register.go, fakerelay/internal/world/types.go, fakerelay/internal/world/validate.go, docs/fakerelay.md
T2 test writer fakerelay/prooftest/adopt_test.go (new), e2e/contract/s_adopt_test.go (new), fakerelay/prooftest/outside_test.go (only the live-row claudeSessionId assertions in TestOutsideFixtureClaudeSessionIDServed and TestOutsideFixtureClaudeSessionIDRefused)
Verification none here; relay#532

docs/fakerelay.md (T1)

  • Surface, Route classes: add POST /api/sessions/adopt to the execute Sessions row.
  • Wire reference: a new section "### Adopt (G9)" after Sessions: the body, the judging order with every code, status and text above, the 201 shape, the event.
  • Gaps and sources: row G9: the adopt door's messages, judging order and event keys; source routes.md and outside-sessions.md for the codes, the planner's check of relay for the texts and order; checked against relay at 552a068.
  • Outside fixture sessions: the world-key table above; the success, launch_failed and stop_failed effects on the row; the live-row claudeSessionId sentence replaces relay#524's refusal sentence.
  • Fake semantics: the outcome comes from the fixture; adopt.refuse codes are judged after the row resolves (in relay audit_unavailable and unavailable come first); the recovery text's call id is e<index>.
  • Not faked: the signal ladder and its waits, the registry and transcript reads, the console probe a claudeSessionId selector runs, and the four audit rows of decision 6.

Acceptance mapping

Criterion Task, interface Test (T2)
1 fakerelay serves adoptable and adoptReason on outside rows T1 world keys, outsideRow, OutsideAdoptReason TestAdoptRowFieldsServed: list, detail and WS snapshot each serve adoptable:true and no adoptReason on an adoptable row; adoptable:false and each default (ended, host_unsupported, no_project, not_registered) on rows without keys; a set adopt_reason (not_interactive); claudeSessionId on a live row
1 (fixture rules) T1 ValidateOutside TestAdoptFixtureRefused: PUT answers 400 for each rule in the world-key table (adoptable on ended, host or project-less rows; short id; non-UUID or missing conversation id; reason with adoptable; unknown or contradicting reason; adopt without adoptable; each bad adopt value; unknown adopt key)
2 success shape T1 step 11 TestAdoptIdleSucceeds: 201 with exactly four keys; claudeSessionId is the row's; interrupted and recoverySent false; the new session is listed, headless, attended idle, with the row's name and directory and the model; the body's model beats model_family; the row is gone from list and snapshot (revision up); session.adopt is ok with five keys; a second adopt answers 404
2 interrupt T1 steps 7, 11, recovery text TestAdoptBusyInterrupt: busy without interrupt answers 409 busy naming the veto and the row stays; with interrupt:true 201 interrupted:true, recoverySent:true; session_joined history holds a user message with origin relay-recovery naming the open call's name and id and "Check whether it took effect"; an idle row with interrupt:true answers interrupted:false and sends nothing
2 refusal codes T1 steps 1 to 10 TestAdoptRefusals, table-driven: each status, error and message above, with the row unchanged after each (except launch_failed, which reads ended with the clock's ended_at); managed by re-putting the row after a success; ambiguous with two console rows; one launch refusal (model_not_allowed) and directory_outside_project; the event's outcome is denied below 500 and error above
2 door class T1 route TestAdoptDoorClass: scoped 403 Forbidden; a read-only bearer 403; the TCP listener does not route it
3 the contract test covers the door and the fields T2 TestAdoptContractRefusals in e2e/contract/s_adopt_test.go, surface Sessions, run on fakerelay and the test-build relay with an execute credential: every step-1 refusal, 404 for an unknown outside:<32 hex> and for an unknown UUID, scoped 403. The transcripts must match. Live rows cannot be staged on the real relay here, so the row and success paths are proved in prooftest

Test surface

  • fakerelay prooftest ((cd fakerelay && go test -race ./prooftest/)): every row above but the last.
  • Contract scenario ((cd e2e && go test -race -count=1 ./contract/ -run TestAdoptContract)): the last row. Where the real relay answers differently, fakerelay follows the real relay and the PR names the step.
  • Break-and-restore: remove the adopt.busy check in step 7; TestAdoptBusyInterrupt goes red. Restore byte for byte.
  • No e2e feature test, no journey, no Playwright, no real-app check: nothing user-visible.

Waits

  • T1: none; it adds no tests. Step 11's removal, snapshot push and event finish before the 201; beginTurn records the recovery message before the 201.
  • T2 prooftest: instance ready is startInstance (the stdout line). The door's answer is the HTTP response. session.adopt is waitEvent (append notice). The replacement snapshot is until on a frame whose revision exceeds the one before. The recovery message is read from session_joined history after a join_session sent after the 201. launch_failed's ended_at: ctl clock set first, then compare.
  • T2 contract: HTTP responses only.

Budget

  • T1: 10 files (1 new), about 450 changed lines (handler about 220, world and validation about 80, the outside seam about 40, docs about 110).
  • T2: 3 files (2 new), about 450 changed lines (prooftest about 350, contract scenario about 80, relay#524 edits about 20).
  • Fix attempts: two, then one on the default model. More files, about 1.5x the lines, or 3x the tokens is a question for the coordinator.

Verification task

None in this child. The close child relay#532 runs it on the epic head: (cd e2e && go test -race ./features/), stack-up r532, go run ./cmd/devboxverify --stack <stack.json> --phase api, stack-down r532, and its Verified: line. That suite does not run fakerelay/prooftest or e2e/contract, so this PR runs both commands under Test surface once on its head and puts the results in its body. PR evidence lines: Review, Break-and-restore, Journey: n/a: nothing user-visible; the epic close child runs devboxverify on the epic head, and Weakening reviewed: for the two relay#524 assertions (question 1).

Open questions

  1. INSIDE. Lifting the live-row claude_session_id refusal changes two relay#524 prooftest assertions. Recommendation: lift it. relay#527 decision 2 and its accepted question 1 put the id on live rows, and the success shape's claudeSessionId is the row's. The PR carries Weakening reviewed: relay#524 live-row claudeSessionId refusal follows relay#527 decision 2.
  2. INSIDE. Fixtures without the new keys. Recommendation: decision 2's derived reason, failing closed, so eve's existing fixtures load unchanged when eve#597 bumps the pin.
  3. INSIDE. The recovery message is not named in the criteria. Recommendation: send it; without it recoverySent would be false where relay says true, and eve#597 renders the relay-recovery origin.
  4. INSIDE. "The fakerelay contract test" could mean fakerelay/prooftest (relay#535's precedent) or e2e/contract. Recommendation: both, split by what each target can stage.

Activity

  1. added
    enhancementNew feature or request
    needs-reviewAwaiting owner review before work starts
    from-devIdea found during development, not asked for by the owner
    on Oct 20, 2026
  2. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-20. Clears a block in the approved design: eve#597 (approved by the owner) cannot be tested against fakerelay without it. Child of relay#525; the PR targets epic/525-session-adoption.

  3. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Merged into epic/525-session-adoption by #560 (2faa421). The close child closes this issue.

  4. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Done by #560, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfrom-devIdea found during development, not asked for by the owner

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions