Repository navigation
Description
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
adoptableandadoptReasonon outside rows, as relay#527 pins them. - fakerelay answers
POST /api/sessions/adoptwith 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:
- Session-host readiness (
502 unavailable) is checked straight after audit readiness, before the row. The contract put it aftermanaged. - A selector that names an ended row answers
409 ended. AclaudeSessionIdthat two live console rows hold answers409 ambiguouswith its own message. modelin the body is checked: at most 200 characters, valid UTF-8, no control characters, else400 invalid_body. Data after the JSON value is400 invalid_body.- A parse refusal writes
session.adoptwith no keys. Every later answer writes it with all five keys. - A live row with no conversation id carries no
claudeSessionId(omitempty).
Decisions
- Rows. Every outside row carries
adoptable. A row that is not adoptable carriesadoptReason. A live row may carryclaudeSessionId, as relay#527 decision 2 serves it. This lifts relay#524's fakerelay rule "a live row withclaude_session_idis refused" (question 1). - Absent keys fail closed. A fixture row with no
adoptablereadsadoptable:false. Its reason is the first rule that applies, in relay's order:ended(ended row),host_unsupported(host_idset),no_project(project_idnull), elsenot_registered. Existing fixtures stay valid (question 2). - The outcome comes from the fixture. fakerelay runs no process, sends no signal and reads no registry or transcript file. The row's
adoptobject 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. - 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.
- 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). - Audit rows are not faked. fakerelay writes no
session_adopt,session_interrupt,session_messageorsession_launchrow, 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):
- Body, at most 4096 bytes, one JSON value, unknown fields refused.
- over the cap:
413 body_too_largerequest body is larger than 4 KiB - not JSON, unknown field, wrong type, trailing data:
400 invalid_bodybody must be JSON {"outsideId" or "claudeSessionId", "interrupt", "model"} - both selectors or neither:
400 invalid_bodysend outsideId or claudeSessionId, not both and not neither outsideIdnot^outside:[0-9a-f]{32}$:400 invalid_bodyoutsideId is not an outside session idclaudeSessionIdnot a UUID:400 invalid_bodyclaudeSessionId is not a UUID- bad
model(merged-code note 3):400 invalid_bodymodel is not a model name
- over the cap:
- Resolve the row.
outsideId: the row with that id. None:404 outside_session_not_foundno live outside session matches. An ended row:409 endedthe session has ended; there is no process to adopt.claudeSessionId: the live rows withhost_idnull and thatclaude_session_id. None:409 ended(same text) when any ended row carries the id, else the 404. Two or more:409 ambiguousmore than one live process holds this conversation.
- A row with
host_idset:409 host_unsupportedsessions on an SSH host cannot be adopted. A row not adoptable:409 <adoptReason>the session cannot be adopted: <adoptReason>. adopt.refuseof the early kind:audit_unavailable:503auditing is off; no session can be adoptedunavailable:502the session host is not availableadopt_in_progress:409an adopt of this conversation is already runningchanged:409the process or its registry entry changed
project_idnames no world project, or a host or remote one:409 no_projectthe session is not in a registered local project.- A fakerelay session made by an earlier adopt holds the conversation id:
409 managedrelay already holds this conversation. adopt.busyset andinterruptfalse:409 busythe session is not idle (<veto>); ask to interrupt to take it over anyway.- Model: the body's
model, elseadopt.model_family. Neither:409 model_unknownthe model of the session is unknown; send model. - 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_allowedunchanged) and its text. adopt.refuseof the late kind:stop_failed:502the process did not exit after SIGKILL; nothing was launched. The row stays.launch_failed:502the process ended but relay could not start the session; the conversation is intact and can be resumed. The row becomes ended:state:"ended",ended_atthe instance clock's now (RFC 3339 UTC, seconds),adoptdropped,claude_session_idkept; it then readsadoptable:false,adoptReason:"ended".
- Success, in this order:
- Remove the row (fails if it is already gone: the step-2 404).
revisiongoes up by one, every/ws/outside-sessionsreader gets the replacement snapshot, andoutside_sessions.changedis written, asPUT /v1/outside-sessionsdoes today. - Launch a session as
POST /api/sessionswould with{"projectId", "directory": <row directory>, "name": <row name>, "model": <step 8>, "settings": {"headless":true,"agent":true}}, kindclaude, no origin. It is listed,headless:true, attended (attention.stateidle), and can be dropped into. It records the conversation id for step 6. interruptedis true whenadopt.busywas set (an idle row withinterrupt:trueis not interrupted). When interrupted, send the recovery text through the turn path the Chief of Staff message uses (beginTurnbefore the answer,runTurnafter), originrelay-recovery.recoverySentis true whenbeginTurnaccepted it.- Answer
201{"sessionId","claudeSessionId","interrupted","recoverySent"}, exactly these four keys.
- Remove the row (fails if it is already gone: the step-2 404).
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
adoptablerow and taps. - Power: the world file and
PUT /v1/outside-sessions: a test picks each outcome withadopt.busy,adopt.model_familyandadopt.refuse. Noctlverb; 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/adoptto theexecuteSessions 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; sourceroutes.mdandoutside-sessions.mdfor the codes, the planner's check of relay for the texts and order; checked against relay at552a068. - Outside fixture sessions: the world-key table above; the success,
launch_failedandstop_failedeffects on the row; the live-rowclaudeSessionIdsentence replaces relay#524's refusal sentence. - Fake semantics: the outcome comes from the fixture;
adopt.refusecodes are judged after the row resolves (in relayaudit_unavailableandunavailablecome first); the recovery text's call id ise<index>. - Not faked: the signal ladder and its waits, the registry and transcript reads, the console probe a
claudeSessionIdselector 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.busycheck in step 7;TestAdoptBusyInterruptgoes 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;
beginTurnrecords the recovery message before the 201. - T2 prooftest: instance ready is
startInstance(the stdout line). The door's answer is the HTTP response.session.adoptiswaitEvent(append notice). The replacement snapshot isuntilon a frame whoserevisionexceeds the one before. The recovery message is read fromsession_joinedhistory after ajoin_sessionsent after the 201.launch_failed'sended_at:ctl clock setfirst, 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
- INSIDE. Lifting the live-row
claude_session_idrefusal 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'sclaudeSessionIdis the row's. The PR carriesWeakening reviewed: relay#524 live-row claudeSessionId refusal follows relay#527 decision 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.
- INSIDE. The recovery message is not named in the criteria. Recommendation: send it; without it
recoverySentwould be false where relay says true, and eve#597 renders therelay-recoveryorigin. - INSIDE. "The fakerelay contract test" could mean
fakerelay/prooftest(relay#535's precedent) ore2e/contract. Recommendation: both, split by what each target can stage.
Activity
barelyworkingcode commentedon Oct 21, 2026
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.
barelyworkingcode commentedon Oct 21, 2026
Merged into epic/525-session-adoption by #560 (2faa421). The close child closes this issue.
barelyworkingcode commentedon Oct 21, 2026
Done by #560, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).