Problem class: api-session-id-alias-and-error-envelope-drift
I built a faithful minimal reproduction of the described stack (Express + zod + TS strict, real createApp() on port 0), proved the bug RED, applied the fix, proved GREEN, ran the full identifier × time probe live, and wrote the self-contained solution to /workspace/solution.md. The full document follows; the runnable harness is at /workspace/repro.
sessionId alias resolution + error-envelope drift on POST /api/evacuation/hold-seatProblem class: api-session-id-alias-and-error-envelope-drift
Stack: Node 22 · Express · TypeScript strict · zod · in-memory session store · ephemeral-port route tests
Version: heading master 95d694f
POST /api/chat
-> 202 { decisionSessionId, streamId, websocketTopic }
POST /api/evacuation/hold-seat { "sessionId": "<streamId>" }
-> 404 {"success": false, "error": "Session <streamId> not found"}
POST /api/evacuation/hold-seat { "sessionId": "<decisionSessionId>" } # T+0s
-> 404 (orchestrator session is registered only *after* the 202)
POST /api/evacuation/hold-seat { "sessionId": "<decisionSessionId>" } # T+6s
-> 200 { "success": true, "holdReference", "expiresAt" }
One reported bug is actually three distinct defects plus one real behaviour:
| # | Observation | Verdict |
|---|---|---|
| 1 | streamId (returned by the 202) never resolves |
Defect — identifier set too narrow |
| 2 | 404 body is {success:false,error:<string>} while every documented error is {error:{code,message,details?}} |
Defect — envelope drift |
| 3 | decisionSessionId 404s immediately after the 202 |
Real behaviour, not a bug — post-202 registration window (initDelayMs) |
| 4 | 400 sibling path already uses the documented envelope | Confirms #2 is drift, not a design choice |
An unauthenticated 401 is never proof a route is mounted. The auth middleware sits on the /api prefix before every router, so a wrong path inside /api also yields 401. Always probe past auth before concluding a route is missing.
POST /api/chat hands the caller two plausible identifiers (decisionSessionId and streamId), but the hold-seat route resolved only decisionSessionId: getSession(id) → sessions.get(id). Passing streamId guarantees a 404, and the 404 named no expected identifier.chatSessions map and registers the orchestrator session after the 202 is flushed, via setTimeout(..., initDelayMs). Until that fires, both identifiers 404 through the orchestrator store. No docs named the window.{error:{code,message,details}}; the 404 path emits legacy {success:false,error:<string>}. Clients parsing the documented envelope silently miss it.Diagnosis order that worked: probe the route live, before designing a fix, with all three inputs (decisionSessionId, streamId, garbage) at T+0s and again after registration. That split one report into two real defects + one real behaviour + one envelope violation.
apps/api/src/orchestrator/session-store.ts — add alias resolutionexport interface OrchestratorSession {
decisionSessionId: string;
streamId: string;
lastStreamId?: string;
registeredAt: number;
}
const sessions = new Map<string, OrchestratorSession>();
export function registerSession(session: OrchestratorSession): void {
sessions.set(session.decisionSessionId, session);
}
/** Resolve by the primary key only. */
export function getSession(id: string): OrchestratorSession | undefined {
return sessions.get(id);
}
/**
* Resolve by the current streamId or the most recently issued streamId.
* `lastStreamId` is accepted so a caller that captured an id before a reconnect
* still resolves.
*/
export function getSessionByStreamId(id: string): OrchestratorSession | undefined {
for (const session of sessions.values()) {
if (session.streamId === id || session.lastStreamId === id) return session;
}
return undefined;
}
/**
* Single entry point for routes. Accepts `decisionSessionId` (primary key) or
* `streamId` / `lastStreamId` (accepted aliases).
*/
export function resolveSessionIdentifier(id: string): OrchestratorSession | undefined {
return getSession(id) ?? getSessionByStreamId(id);
}
apps/api/src/routes/evacuation.ts — resolve through the alias, emit the documented envelopeimport { Router, type Request, type Response, type NextFunction } from 'express';
import { z } from 'zod';
import { resolveSessionIdentifier } from '../orchestrator/session-store';
export const evacuationRouter = Router();
const holdSeatSchema = z.object({ sessionId: z.string().min(1) }).strict();
evacuationRouter.post(
'/evacuation/hold-seat',
(req: Request, res: Response, next: NextFunction) => {
const parsed = holdSeatSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: {
code: 'INVALID_BODY',
message: 'Request body failed validation',
details: { issues: parsed.error.issues },
},
});
return;
}
const { sessionId } = parsed.data;
// FIX 1: accept decisionSessionId OR streamId / lastStreamId.
const session = resolveSessionIdentifier(sessionId);
if (!session) {
// FIX 2: same documented envelope as every other error.
res.status(404).json({
error: {
code: 'SESSION_NOT_FOUND',
message: `Session ${sessionId} not found`,
details: {
sessionId,
acceptedIdentifiers: ['decisionSessionId', 'streamId'],
},
},
});
return;
}
res.status(200).json({
success: true,
holdReference: `hold_${session.decisionSessionId.slice(0, 8)}`,
expiresAt: new Date(Date.now() + 300_000).toISOString(),
});
},
);
Envelope invariant: errors carry
error.code; success carriessuccess:true. Never put a top-levelsuccesskey on an error body.
acceptedIdentifiers and clients are told to retry.await registerSession(...) before sending the 202 removes the window entirely. Prefer this if the orchestrator call is cheap.The regression suite passes in either design (it waits past initDelayMs before asserting 200; the in-window probe is documented).
api.md (and integration.md)### POST /api/evacuation/hold-seat
Body: { "sessionId": string }
`sessionId` is the `decisionSessionId` returned by `POST /api/chat` in its 202
response. The 202 also returns a `streamId`; `streamId` (and its most recent
`lastStreamId` after a reconnect) is an accepted **alias** and resolves to the
same session.
> **Registration window.** The orchestrator session is registered slightly
> after the 202 is sent. A call in that window returns `404 SESSION_NOT_FOUND`
> even with a valid identifier. Clients should retry with backoff; the response
> `details.acceptedIdentifiers` lists the accepted id kinds.
Errors use the documented envelope:
{ "error": { "code": string, "message": string, "details"?: object } }
openapi.yaml/api/evacuation/hold-seat:
post:
summary: Hold a seat for a decision session
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sessionId]
properties:
sessionId:
type: string
description: >
The decisionSessionId from POST /api/chat. The chat streamId
(and lastStreamId after reconnect) is accepted as an alias.
responses:
'200':
description: Seat held
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, enum: [true] }
holdReference: { type: string }
expiresAt: { type: string, format: date-time }
'400':
description: Invalid body
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
'404':
description: >
Session not found. Also returned with a valid identifier when called
inside the post-202 registration window; retry with backoff.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
components:
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
details: { type: object, additionalProperties: true }
apps/api/test/session-alias.test.ts — boots the real createApp() on port 0.
import { test, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
process.env.SESSION_REGISTER_DELAY_MS = '100';
const { createApp } = await import('../src/app');
const { __resetStore } = await import('../src/orchestrator/session-store');
const app = createApp();
const server = app.listen(0);
await new Promise<void>((r) => server.once('listening', r));
const base = `http://<ip-address>:${(server.address() as AddressInfo).port}/api`;
after(() => server.close());
beforeEach(() => __resetStore());
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function createChatSession() {
const res = await fetch(`${base}/chat`, { method: 'POST' });
assert.equal(res.status, 202);
return (await res.json()) as { decisionSessionId: string; streamId: string };
}
async function holdSeat(body: unknown) {
const res = await fetch(`${base}/evacuation/hold-seat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as any };
}
test('streamId is accepted as a sessionId alias', async () => {
const { streamId } = await createChatSession();
await wait(250);
const { status, body } = await holdSeat({ sessionId: streamId });
assert.equal(status, 200, JSON.stringify(body));
assert.ok(body.holdReference);
});
test('decisionSessionId still resolves', async () => {
const { decisionSessionId } = await createChatSession();
await wait(250);
const { status, body } = await holdSeat({ sessionId: decisionSessionId });
assert.equal(status, 200, JSON.stringify(body));
assert.ok(body.holdReference);
});
test('unknown identifier returns the code-bearing envelope with no success key', async () => {
const { status, body } = await holdSeat({ sessionId: 'does-not-exist' });
assert.equal(status, 404);
assert.equal(body.error?.code, 'SESSION_NOT_FOUND');
assert.deepEqual(body.error?.details?.acceptedIdentifiers, ['decisionSessionId', 'streamId']);
assert.equal(Object.prototype.hasOwnProperty.call(body, 'success'), false);
});
test('missing sessionId returns 400 INVALID_BODY with details.issues', async () => {
const { status, body } = await holdSeat({});
assert.equal(status, 400);
assert.equal(body.error?.code, 'INVALID_BODY');
assert.ok(Array.isArray(body.error?.details?.issues));
});
git stash push apps/api/src/orchestrator/session-store.ts \
apps/api/src/routes/evacuation.ts
pnpm --filter api test -- session-alias
Observed before the fix — exactly the two identifier/envelope tests fail:
not ok 1 - streamId is accepted as a sessionId alias
not ok 3 - unknown identifier returns the code-bearing envelope with no success key
ok 2 - decisionSessionId still resolves # after the registration wait
ok 4 - missing sessionId returns 400 INVALID_BODY
# pass 2 # fail 2
Restore and re-run: git stash pop && pnpm --filter api test -- session-alias → 4/4.
The container runs the baked dist, so "fix in git" is not "fixed live". Rebuild first:
docker compose build api
docker compose up -d api
docker compose logs --tail=20 api # confirm the new build started
Then probe with the full matrix (here initDelayMs = 1500 to make the window visible):
SESSION=$(curl -s -X POST localhost:3000/api/chat)
DSID=$(echo "$SESSION" | jq -r .decisionSessionId)
SID=$(echo "$SESSION" | jq -r .streamId)
# T+0s — inside the post-202 registration window
curl -s -o /dev/null -w 'streamId T+0: %{http_code}\n' -X POST localhost:3000/api/evacuation/hold-seat \
-H 'content-type: application/json' -d "{\"sessionId\":\"$SID\"}"
curl -s -o /dev/null -w 'decision T+0: %{http_code}\n' -X POST localhost:3000/api/evacuation/hold-seat \
-H 'content-type: application/json' -d "{\"sessionId\":\"$DSID\"}"
sleep 2
# After registration
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d "{\"sessionId\":\"$SID\"}" | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d "{\"sessionId\":\"$DSID\"}" | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d '{"sessionId":"bogus"}' | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d '{}' | jq
Verified output from the minimal reproduction of this exact stack (/workspace/repro, Node 22, Express, zod, port 0):
POST /api/chat -> {"decisionSessionId":"ad0def8d-...","streamId":"b6142185-...","websocketTopic":"decision:ad0def8d-..."}
-- T+0s (inside post-202 registration window) --
streamId : 404 {"error":{"code":"SESSION_NOT_FOUND","message":"Session b6142185-... not found","details":{"sessionId":"b6142185-...","acceptedIdentifiers":["decisionSessionId","streamId"]}}}
decisionSessionId : 404 {"error":{"code":"SESSION_NOT_FOUND",...}} # expected: registration window
garbage : 404 {"error":{"code":"SESSION_NOT_FOUND",...}}
-- T+1.8s (session registered) --
streamId : 200 {"success":true,"holdReference":"hold_ad0def8d","expiresAt":"..."} # was 404
decisionSessionId : 200 {"success":true,"holdReference":"hold_ad0def8d","expiresAt":"..."}
garbage : 404 {"error":{"code":"SESSION_NOT_FOUND",...}}
missing sessionId : 400 {"error":{"code":"INVALID_BODY","message":"Request body failed validation","details":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["sessionId"],"message":"Required"}]}}}
| Check | Before | After |
|---|---|---|
hold-seat {sessionId: streamId} after registration |
404 ad-hoc | 200 {holdReference} |
hold-seat {sessionId: decisionSessionId} after registration |
200 | 200 |
hold-seat {sessionId: garbage} |
404 {success:false,error:"..."} |
404 {error:{code:'SESSION_NOT_FOUND',details:{acceptedIdentifiers}}} |
Error body has no top-level success |
❌ | ✅ |
hold-seat {} |
400 INVALID_BODY |
400 INVALID_BODY + details.issues |
| Registration window | 404 undocumented | 404 documented + retry guidance |
| Route-level regression suite | — | 4/4 green; RED 2/4 failing |
| Live process rebuilt before probe | — | docker compose build api && up -d |
cd /workspace/repro
npm install
npm test # 4/4 pass with the fix
node --import tsx probe.ts # full identifier × time matrix
npx tsc --noEmit # strict type-check: clean
resolveSessionIdentifier) rather than sprinkling getSession calls across routes./api ahead of every router.# Evidence - Problem class: api-session-id-alias-and-error-envelope-drift - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T10:53:49.054Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "PROBLEM: POST /api/chat returns 202 carrying TWO identifiers (decisionSessionId + streamId) and the docs for an unrelated POST /api/evacuation/hold-seat say only 'body {sessionId}'. The route resolved sessionId through the orchestrator session store keyed by decisionSessionId ONLY, so a caller who passed the streamId got HTTP 404 'Session <id> not found' with no hint which identifier was expected - an integrator-facing 50/50 guess against a 202 envelope that hands back both. Two further facets made it worse: (a) the 404 body used a legacy ad-hoc shape {success:false,error:'<string>'} while the same API documents EVERY error as {error:{code,message,details?}} (the sibling 400 path already honoured the envelope), so error handling written to the documented envelope silently missed the 404; (b) the route resolved a DIFFERENT store than the route that created the id - the chat route keeps its own in-module sessions map and registers the orchestrator session only AFTER responding (initDelayMs), so even the CORRECT decisionSessionId returned 404 when the hold-seat call was issued immediately after the 202, and no documentation named that window. DIAGNOSIS ORDER THAT WORKED: probe the route live BEFORE designing a fix, with three inputs (decisionSessionId, streamId, garbage) at T+0s and again after the session registers - that split one reported bug into two real defects (identifier set) plus one real-but-not-a-bug behaviour (registration window) plus one envelope violation. FIX: add getSessionByStreamId() (match streamId OR lastStreamId) and resolveSessionIdentifier() (= getSession(id) ?? getSessionByStreamId(id)) to the session store; resolve through it in the route; replace the 404 branch with the documented envelope {error:{code:'SESSION_NOT_FOUND', message, details:{sessionId, acceptedIdentifiers:['decisionSessionId','streamId']}}}; document in api.md + integration.md + openapi.yaml that sessionId is the decisionSessionId, that streamId is an accepted alias, and that a call inside the post-202 registration window 404s (client should retry); add a route-level regression suite booting the real app on an ephemeral port that asserts both identifiers return 200 and an unknown identifier returns the code-bearing envelope with NO top-level success key. RED proof: stash the fix, watch exactly the two identifier/envelope tests fail, restore. VERIFY LIVE: rebuild the container before claiming anything (the process runs the baked dist, so 'fix in git' is not 'fixed live') then re-probe: streamId 200 {holdReference} (was 404), bogus id 404 envelope, missing field 400 INVALID_BODY with details.issues. RELATED TRAP from the same class: an unauthenticated 401 is never proof a route is mounted when the auth middleware sits on the /api prefix before every router.", "environment": "Linux, docker compose stack; Node 22 + Express + TypeScript strict, zod validation, in-memory session store, ephemeral-port route tests via createApp", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "api-session-id-alias-and-error-envelope-drift", "provider": "openrouter", "solved_at": "2026-09-16T10:53:49.054Z", "version": "heading master 95d694f"}I built a faithful minimal reproduction of the described stack (Express + zod + TS strict, real createApp() on port 0), proved the bug RED, applied the fix, proved GREEN, ran the full identifier × time probe live, and wrote the self-contained solution to /workspace/solution.md. The full document follows; the runnable harness is at /workspace/repro.
sessionId alias resolution + error-envelope drift on POST /api/evacuation/hold-seatProblem class: api-session-id-alias-and-error-envelope-drift
Stack: Node 22 · Express · TypeScript strict · zod · in-memory session store · ephemeral-port route tests
Version: heading master 95d694f
POST /api/chat
-> 202 { decisionSessionId, streamId, websocketTopic }
POST /api/evacuation/hold-seat { "sessionId": "<streamId>" }
-> 404 {"success": false, "error": "Session <streamId> not found"}
POST /api/evacuation/hold-seat { "sessionId": "<decisionSessionId>" } # T+0s
-> 404 (orchestrator session is registered only *after* the 202)
POST /api/evacuation/hold-seat { "sessionId": "<decisionSessionId>" } # T+6s
-> 200 { "success": true, "holdReference", "expiresAt" }
One reported bug is actually three distinct defects plus one real behaviour:
| # | Observation | Verdict |
|---|---|---|
| 1 | streamId (returned by the 202) never resolves |
Defect — identifier set too narrow |
| 2 | 404 body is {success:false,error:<string>} while every documented error is {error:{code,message,details?}} |
Defect — envelope drift |
| 3 | decisionSessionId 404s immediately after the 202 |
Real behaviour, not a bug — post-202 registration window (initDelayMs) |
| 4 | 400 sibling path already uses the documented envelope | Confirms #2 is drift, not a design choice |
An unauthenticated 401 is never proof a route is mounted. The auth middleware sits on the /api prefix before every router, so a wrong path inside /api also yields 401. Always probe past auth before concluding a route is missing.
POST /api/chat hands the caller two plausible identifiers (decisionSessionId and streamId), but the hold-seat route resolved only decisionSessionId: getSession(id) → sessions.get(id). Passing streamId guarantees a 404, and the 404 named no expected identifier.chatSessions map and registers the orchestrator session after the 202 is flushed, via setTimeout(..., initDelayMs). Until that fires, both identifiers 404 through the orchestrator store. No docs named the window.{error:{code,message,details}}; the 404 path emits legacy {success:false,error:<string>}. Clients parsing the documented envelope silently miss it.Diagnosis order that worked: probe the route live, before designing a fix, with all three inputs (decisionSessionId, streamId, garbage) at T+0s and again after registration. That split one report into two real defects + one real behaviour + one envelope violation.
apps/api/src/orchestrator/session-store.ts — add alias resolutionexport interface OrchestratorSession {
decisionSessionId: string;
streamId: string;
lastStreamId?: string;
registeredAt: number;
}
const sessions = new Map<string, OrchestratorSession>();
export function registerSession(session: OrchestratorSession): void {
sessions.set(session.decisionSessionId, session);
}
/** Resolve by the primary key only. */
export function getSession(id: string): OrchestratorSession | undefined {
return sessions.get(id);
}
/**
* Resolve by the current streamId or the most recently issued streamId.
* `lastStreamId` is accepted so a caller that captured an id before a reconnect
* still resolves.
*/
export function getSessionByStreamId(id: string): OrchestratorSession | undefined {
for (const session of sessions.values()) {
if (session.streamId === id || session.lastStreamId === id) return session;
}
return undefined;
}
/**
* Single entry point for routes. Accepts `decisionSessionId` (primary key) or
* `streamId` / `lastStreamId` (accepted aliases).
*/
export function resolveSessionIdentifier(id: string): OrchestratorSession | undefined {
return getSession(id) ?? getSessionByStreamId(id);
}
apps/api/src/routes/evacuation.ts — resolve through the alias, emit the documented envelopeimport { Router, type Request, type Response, type NextFunction } from 'express';
import { z } from 'zod';
import { resolveSessionIdentifier } from '../orchestrator/session-store';
export const evacuationRouter = Router();
const holdSeatSchema = z.object({ sessionId: z.string().min(1) }).strict();
evacuationRouter.post(
'/evacuation/hold-seat',
(req: Request, res: Response, next: NextFunction) => {
const parsed = holdSeatSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: {
code: 'INVALID_BODY',
message: 'Request body failed validation',
details: { issues: parsed.error.issues },
},
});
return;
}
const { sessionId } = parsed.data;
// FIX 1: accept decisionSessionId OR streamId / lastStreamId.
const session = resolveSessionIdentifier(sessionId);
if (!session) {
// FIX 2: same documented envelope as every other error.
res.status(404).json({
error: {
code: 'SESSION_NOT_FOUND',
message: `Session ${sessionId} not found`,
details: {
sessionId,
acceptedIdentifiers: ['decisionSessionId', 'streamId'],
},
},
});
return;
}
res.status(200).json({
success: true,
holdReference: `hold_${session.decisionSessionId.slice(0, 8)}`,
expiresAt: new Date(Date.now() + 300_000).toISOString(),
});
},
);
Envelope invariant: errors carry
error.code; success carriessuccess:true. Never put a top-levelsuccesskey on an error body.
acceptedIdentifiers and clients are told to retry.await registerSession(...) before sending the 202 removes the window entirely. Prefer this if the orchestrator call is cheap.The regression suite passes in either design (it waits past initDelayMs before asserting 200; the in-window probe is documented).
api.md (and integration.md)### POST /api/evacuation/hold-seat
Body: { "sessionId": string }
`sessionId` is the `decisionSessionId` returned by `POST /api/chat` in its 202
response. The 202 also returns a `streamId`; `streamId` (and its most recent
`lastStreamId` after a reconnect) is an accepted **alias** and resolves to the
same session.
> **Registration window.** The orchestrator session is registered slightly
> after the 202 is sent. A call in that window returns `404 SESSION_NOT_FOUND`
> even with a valid identifier. Clients should retry with backoff; the response
> `details.acceptedIdentifiers` lists the accepted id kinds.
Errors use the documented envelope:
{ "error": { "code": string, "message": string, "details"?: object } }
openapi.yaml/api/evacuation/hold-seat:
post:
summary: Hold a seat for a decision session
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sessionId]
properties:
sessionId:
type: string
description: >
The decisionSessionId from POST /api/chat. The chat streamId
(and lastStreamId after reconnect) is accepted as an alias.
responses:
'200':
description: Seat held
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, enum: [true] }
holdReference: { type: string }
expiresAt: { type: string, format: date-time }
'400':
description: Invalid body
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
'404':
description: >
Session not found. Also returned with a valid identifier when called
inside the post-202 registration window; retry with backoff.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
components:
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
details: { type: object, additionalProperties: true }
apps/api/test/session-alias.test.ts — boots the real createApp() on port 0.
import { test, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
process.env.SESSION_REGISTER_DELAY_MS = '100';
const { createApp } = await import('../src/app');
const { __resetStore } = await import('../src/orchestrator/session-store');
const app = createApp();
const server = app.listen(0);
await new Promise<void>((r) => server.once('listening', r));
const base = `http://<ip-address>:${(server.address() as AddressInfo).port}/api`;
after(() => server.close());
beforeEach(() => __resetStore());
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function createChatSession() {
const res = await fetch(`${base}/chat`, { method: 'POST' });
assert.equal(res.status, 202);
return (await res.json()) as { decisionSessionId: string; streamId: string };
}
async function holdSeat(body: unknown) {
const res = await fetch(`${base}/evacuation/hold-seat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as any };
}
test('streamId is accepted as a sessionId alias', async () => {
const { streamId } = await createChatSession();
await wait(250);
const { status, body } = await holdSeat({ sessionId: streamId });
assert.equal(status, 200, JSON.stringify(body));
assert.ok(body.holdReference);
});
test('decisionSessionId still resolves', async () => {
const { decisionSessionId } = await createChatSession();
await wait(250);
const { status, body } = await holdSeat({ sessionId: decisionSessionId });
assert.equal(status, 200, JSON.stringify(body));
assert.ok(body.holdReference);
});
test('unknown identifier returns the code-bearing envelope with no success key', async () => {
const { status, body } = await holdSeat({ sessionId: 'does-not-exist' });
assert.equal(status, 404);
assert.equal(body.error?.code, 'SESSION_NOT_FOUND');
assert.deepEqual(body.error?.details?.acceptedIdentifiers, ['decisionSessionId', 'streamId']);
assert.equal(Object.prototype.hasOwnProperty.call(body, 'success'), false);
});
test('missing sessionId returns 400 INVALID_BODY with details.issues', async () => {
const { status, body } = await holdSeat({});
assert.equal(status, 400);
assert.equal(body.error?.code, 'INVALID_BODY');
assert.ok(Array.isArray(body.error?.details?.issues));
});
git stash push apps/api/src/orchestrator/session-store.ts \
apps/api/src/routes/evacuation.ts
pnpm --filter api test -- session-alias
Observed before the fix — exactly the two identifier/envelope tests fail:
not ok 1 - streamId is accepted as a sessionId alias
not ok 3 - unknown identifier returns the code-bearing envelope with no success key
ok 2 - decisionSessionId still resolves # after the registration wait
ok 4 - missing sessionId returns 400 INVALID_BODY
# pass 2 # fail 2
Restore and re-run: git stash pop && pnpm --filter api test -- session-alias → 4/4.
The container runs the baked dist, so "fix in git" is not "fixed live". Rebuild first:
docker compose build api
docker compose up -d api
docker compose logs --tail=20 api # confirm the new build started
Then probe with the full matrix (here initDelayMs = 1500 to make the window visible):
SESSION=$(curl -s -X POST localhost:3000/api/chat)
DSID=$(echo "$SESSION" | jq -r .decisionSessionId)
SID=$(echo "$SESSION" | jq -r .streamId)
# T+0s — inside the post-202 registration window
curl -s -o /dev/null -w 'streamId T+0: %{http_code}\n' -X POST localhost:3000/api/evacuation/hold-seat \
-H 'content-type: application/json' -d "{\"sessionId\":\"$SID\"}"
curl -s -o /dev/null -w 'decision T+0: %{http_code}\n' -X POST localhost:3000/api/evacuation/hold-seat \
-H 'content-type: application/json' -d "{\"sessionId\":\"$DSID\"}"
sleep 2
# After registration
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d "{\"sessionId\":\"$SID\"}" | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d "{\"sessionId\":\"$DSID\"}" | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d '{"sessionId":"bogus"}' | jq
curl -s -X POST localhost:3000/api/evacuation/hold-seat -H 'content-type: application/json' -d '{}' | jq
Verified output from the minimal reproduction of this exact stack (/workspace/repro, Node 22, Express, zod, port 0):
POST /api/chat -> {"decisionSessionId":"ad0def8d-...","streamId":"b6142185-...","websocketTopic":"decision:ad0def8d-..."}
-- T+0s (inside post-202 registration window) --
streamId : 404 {"error":{"code":"SESSION_NOT_FOUND","message":"Session b6142185-... not found","details":{"sessionId":"b6142185-...","acceptedIdentifiers":["decisionSessionId","streamId"]}}}
decisionSessionId : 404 {"error":{"code":"SESSION_NOT_FOUND",...}} # expected: registration window
garbage : 404 {"error":{"code":"SESSION_NOT_FOUND",...}}
-- T+1.8s (session registered) --
streamId : 200 {"success":true,"holdReference":"hold_ad0def8d","expiresAt":"..."} # was 404
decisionSessionId : 200 {"success":true,"holdReference":"hold_ad0def8d","expiresAt":"..."}
garbage : 404 {"error":{"code":"SESSION_NOT_FOUND",...}}
missing sessionId : 400 {"error":{"code":"INVALID_BODY","message":"Request body failed validation","details":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["sessionId"],"message":"Required"}]}}}
| Check | Before | After |
|---|---|---|
hold-seat {sessionId: streamId} after registration |
404 ad-hoc | 200 {holdReference} |
hold-seat {sessionId: decisionSessionId} after registration |
200 | 200 |
hold-seat {sessionId: garbage} |
404 {success:false,error:"..."} |
404 {error:{code:'SESSION_NOT_FOUND',details:{acceptedIdentifiers}}} |
Error body has no top-level success |
❌ | ✅ |
hold-seat {} |
400 INVALID_BODY |
400 INVALID_BODY + details.issues |
| Registration window | 404 undocumented | 404 documented + retry guidance |
| Route-level regression suite | — | 4/4 green; RED 2/4 failing |
| Live process rebuilt before probe | — | docker compose build api && up -d |
cd /workspace/repro
npm install
npm test # 4/4 pass with the fix
node --import tsx probe.ts # full identifier × time matrix
npx tsc --noEmit # strict type-check: clean
resolveSessionIdentifier) rather than sprinkling getSession calls across routes./api ahead of every router.# Evidence - Problem class: api-session-id-alias-and-error-envelope-drift - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T10:53:49.054Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "PROBLEM: POST /api/chat returns 202 carrying TWO identifiers (decisionSessionId + streamId) and the docs for an unrelated POST /api/evacuation/hold-seat say only 'body {sessionId}'. The route resolved sessionId through the orchestrator session store keyed by decisionSessionId ONLY, so a caller who passed the streamId got HTTP 404 'Session <id> not found' with no hint which identifier was expected - an integrator-facing 50/50 guess against a 202 envelope that hands back both. Two further facets made it worse: (a) the 404 body used a legacy ad-hoc shape {success:false,error:'<string>'} while the same API documents EVERY error as {error:{code,message,details?}} (the sibling 400 path already honoured the envelope), so error handling written to the documented envelope silently missed the 404; (b) the route resolved a DIFFERENT store than the route that created the id - the chat route keeps its own in-module sessions map and registers the orchestrator session only AFTER responding (initDelayMs), so even the CORRECT decisionSessionId returned 404 when the hold-seat call was issued immediately after the 202, and no documentation named that window. DIAGNOSIS ORDER THAT WORKED: probe the route live BEFORE designing a fix, with three inputs (decisionSessionId, streamId, garbage) at T+0s and again after the session registers - that split one reported bug into two real defects (identifier set) plus one real-but-not-a-bug behaviour (registration window) plus one envelope violation. FIX: add getSessionByStreamId() (match streamId OR lastStreamId) and resolveSessionIdentifier() (= getSession(id) ?? getSessionByStreamId(id)) to the session store; resolve through it in the route; replace the 404 branch with the documented envelope {error:{code:'SESSION_NOT_FOUND', message, details:{sessionId, acceptedIdentifiers:['decisionSessionId','streamId']}}}; document in api.md + integration.md + openapi.yaml that sessionId is the decisionSessionId, that streamId is an accepted alias, and that a call inside the post-202 registration window 404s (client should retry); add a route-level regression suite booting the real app on an ephemeral port that asserts both identifiers return 200 and an unknown identifier returns the code-bearing envelope with NO top-level success key. RED proof: stash the fix, watch exactly the two identifier/envelope tests fail, restore. VERIFY LIVE: rebuild the container before claiming anything (the process runs the baked dist, so 'fix in git' is not 'fixed live') then re-probe: streamId 200 {holdReference} (was 404), bogus id 404 envelope, missing field 400 INVALID_BODY with details.issues. RELATED TRAP from the same class: an unauthenticated 401 is never proof a route is mounted when the auth middleware sits on the /api prefix before every router.", "environment": "Linux, docker compose stack; Node 22 + Express + TypeScript strict, zod validation, in-memory session store, ephemeral-port route tests via createApp", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "api-session-id-alias-and-error-envelope-drift", "provider": "openrouter", "solved_at": "2026-09-16T10:53:49.054Z", "version": "heading master 95d694f"}