TypeScript control plane
Status: the design below was executed on develop; the Python tree
(python/, behave, pytest) is deleted (Kanbus chatticus-f27222). Development
runs the TypeScript control plane. Staging and production run Python until the
develop to main promotion and the migration runbook in this document. Read the
body as the plan the code was built from; where it says "design only" or "on
develop today" it describes the state before the cutover. This was the plan for
the one hard cutover that moved the Python control plane to TypeScript on Pi.
Related: Pi harness, Messaging,
Design challenges, Voice, and
SESSION_NAMESPACE.md (design branch claude/session-namespace-design, not
on develop; its path namespace is orthogonal and changes nothing here).
Decisions already made (inputs, not up for debate)
- Chatticus moves to TypeScript to align with Pi. No new Python.
- One hard cutover moves everything that remains: auth, identity,
organizations, membership, invitations, operator routes,
/me, the spend ceiling; channels, messages, turns, the turn stream, voice messages; the worker protocol, the turn deadline, tasks, the capability policy, grants and approvals; the computer (host routes, computer start, the container host worker, snapshots). The Python front door andControlPlaneare then deleted. The waitlist and contact intake is not ported; it moves to a private repo. - The Pi session (pi-durable, one per bot per channel) is the only transcript. Chatticus is the UI for Pi.
- Gherkin stays the spec and runs under cucumber-js with TypeScript steps.
- No parallel implementations, no backward compatibility, no fallbacks.
- Serverless rules stand: no sockets, the stream is scoped to one turn, no relational database, no load balancer.
- The web
/apicontract is preserved. Every deliberate change is listed in section 0.3.
0. Summary
0.1 The shape in six sentences
- One Node 22 arm64 FrontDoor Lambda (Hono, response streaming on a Function URL) serves every HTTP route, including the one-turn SSE stream.
- One TurnExecutor Lambda (SQS-triggered) is the Pi owner for exactly one turn at a time: it claims the turn, takes the storage fence, opens the bot's Pi session, runs, and closes.
- A TurnProbe Lambda (SQS delay queue) replaces EventBridge Scheduler deadlines and drives recovery; a ComputerStarter Lambda starts the computer host.
- Conversation content lives only in Pi sessions (S3 commit objects plus a
DynamoDB index, the
conversation/storage already on develop). Everything that a non-owner must write while no owner is live (turn control record, mailbox, events, organizations, policy, computer actions) is plain DynamoDB in the existingMessagingtable layout. - The computer container runs a Node host worker that executes tool actions only. It never opens a Pi session. A parked computer tool is a durable action record; the host fills in its result over a nine-route HTTP protocol, and a normal executor resumes the Pi session.
- About 40 small tickets, built dormant on develop, then one flip PR.
0.2 What is on develop today and is reused
conversation/src/storage/: the productionIndexedStorage(S3 commit objects plus a DynamoDB LSI index),table-definition.ts, and the fence methods. 23 of 23 storage conformance cases pass in CI against moto (PI_HARNESS.md).conversation/src/budget/: the budget rollup and alert recorder Lambdas' code and the vendor-ledger and rollup item codecs.conversation/features-support/: the cucumber-js foundation (world.ts,hooks.ts, a moto-backed table) andfeatures/cucumber-features.txt, the manifest that excludes ported features from behave.- Reference only, not on develop: commit
50efa62c(prototype turn runner, Pi worker, SQS handler). Its good ideas are kept (per-turnrequestId,section()system prompt, error classification); its channel sync through the front door is dropped because it made the front door a second transcript.
0.3 Web contract changes (every one is deliberate)
| # | Change | Why |
|---|---|---|
| 1 | POST /channels/{id}/messages addressed to a bot while that channel has an active turn steers the running turn instead of starting a second one. The POST returns 201 with the new message and the same active turn_id; no new turn record and no error. The turn's SSE stream continues unbroken and the steered message shows up in GET .../messages at its own seq. Unaddressed posts are always accepted as before. |
One Pi owner per session, and Pi's whenBusy: "steer" places the message after the current tool round so the answer uses it (PI_HARNESS.md, measured). The iPad voice client's per-channel queue (web/lib/voice-line-delivery.ts) existed only to work around one turn at a time; it becomes redundant and is removed in the same cutover (ticket 40). |
| 2 | turn.token carries real incremental text, coalesced about every 250 ms, instead of two halves. Same event shape. |
Pi streams; the old worker did one non-streaming call. |
| 3 | The committed bot message body is the final assistant text of the turn. Text the model streamed before a tool call is shown live but is not part of the committed message. | One model answer per turn is no longer true; the web already replaces the bubble with the committed message. |
| 4 | turn.reconciling is emitted only for (a) an uncertain Pi commit (CommitOutcomeUnknown) and (b) the stream idle timeout. Never for "provider outcome unknown": Pi simply retries the model call. |
Pi makes provider ambiguity a non-event. |
| 5 | Worker-only routes disappear: workers/{id}/heartbeat (computerless), `turns/{id}/claim |
renew |
| 6 | The 21 host-worker routes become 9 (section 4.3). | The host runs actions, not turns. |
| 7 | No 256-token completion cap; Pi's defaults apply. | The cap was a Chat Completions parameter of the old worker. |
| 8 | GET /channels/{id}/turn and .../turns/latest keep their paths and return the turn of the channel's primary or addressed bot: the most recently started active turn (latest turn for turns/latest), 404 if none. A bot-specific variant is exposed for later use as the optional query parameter ?bot_id={bot} on both paths (returns that bot's active or latest turn; the web does not send it yet). |
Several bots can have active turns on one channel; the existing client must keep working unchanged. |
Unchanged on purpose: paths under /api/orgs/{tenant}, /api/me,
/api/organizations, /api/health; SSE frames event:/id:/data: with
integer seq per turn and Last-Event-ID; terminal kinds
turn.completed|turn.failed|turn.reconciling; 404 for no active or latest
turn; {messages:[]} with integer seq; Idempotency-Key on bots,
channels and voice messages (and accepted on message posts); {detail}
error bodies and the status mapping in _status_for_error.
1. Runtime layout
1.1 Lambdas
| Lambda | Trigger | Runtime | Memory / timeout | Why it exists |
|---|---|---|---|---|
| FrontDoor | Function URL, RESPONSE_STREAM, behind CloudFront /api* |
Node 22 arm64, Hono | 512 MB / 900 s | All HTTP including SSE. Streaming needs a Function URL (no API Gateway, no load balancer). One function so CloudFront keeps one origin; 900 s is the stream lifetime limit, after which the client reconnects with Last-Event-ID. |
| TurnExecutor | SQS TurnRuns, batch 1 |
Node 22 arm64 | 1024 MB / 300 s | The Pi owner. A turn's work must not depend on anyone watching (the invariant), so it is not inline in the request. Measured Pi owner memory is 160 MB; 1024 MB buys CPU. |
| TurnProbe | SQS TurnProbes (delay queue), batch 1 |
Node 22 arm64 | 256 MB / 60 s | Deadline checks and recovery. See 3.5. |
| ComputerStarter | SQS ComputerStartJobs, batch 1 |
Node 22 arm64 | 256 MB / 60 s | Starts the computer host (ECS run_task, cross-account assume-role). Cannot be the executor: it needs ECS/STS/CloudFormation permissions the executor must not hold. |
| DailyBudgetRollup, BudgetAlertRecorder | EventBridge Scheduler, SNS | Node 22 | exists in code on develop | Wiring status in infra to be confirmed by ticket 36. |
| IntegrationTest runner | schedule, on demand | Node 22 | 256 MB / 300 s | Black-box smoke over HTTP (port of integration_test/). |
Not Lambdas: no computer work runs on Lambda (a Lambda cannot hold a browser, a display or a session-lifetime connection). The Node host worker runs in the computer container (section 4).
1.2 Framework
Hono with hono/aws-lambda streamHandle and hono/streaming
streamSSE, validated bodies with zod. Reasons: it is ESM-native (Pi is
ESM only), tiny to bundle (the whole Pi owner path is already 0.7 to 2 MB
bundled), runs Lambda streaming without the Web Adapter layer, and its
app.request() lets cucumber-js drive the real routes in-process with no
sockets. Bundling is esbuild through CDK NodejsFunction
(format: esm, target: node22, the createRequire banner from the Pi
spike).
The Lambda Web Adapter layer, run.sh, Docker pip bundling and the
SseSpike stack are deleted.
Prototype needed (ticket 5): streamHandle plus streamSSE through a Function
URL and CloudFront with a 15 s heartbeat comment and abort on client
disconnect. The Python SSE worked through exactly this path, so the risk is
the Hono adapter, not the platform.
1.3 Inline versus queued turn execution
Queued. Inline execution would tie the turn to the POST or the stream invocation; closing the laptop, a CloudFront timeout or a 900 s stream end would then orphan work. SQS gives retry, a visibility timeout and a DLQ for free, and SQS is explicitly for turn jobs (AGENTS.md). Cost of queueing: about 100 ms to first claim, invisible next to the model call.
A turn longer than a Lambda: the executor watches
context.getRemainingTimeInMillis(); under 30 s it yields (closes the
harness without aborting, releases the claim, re-enqueues itself). The next
owner resumes the same Pi session. This is the same mechanism as a crash
recovery and as the computer handoff, so there is one resume path.
1.4 Queues
| Queue | Producer | Consumer | Notes |
|---|---|---|---|
TurnRuns + DLQ |
FrontDoor (post), TurnProbe (recovery), FrontDoor host result route (resume) | TurnExecutor | Message {kind:"run", tenant_id, turn_id, bot_id, enqueue_id}. Visibility 1800 s (6x function timeout, AWS guidance). New name; old TurnJobs is drained and deleted at cutover so payload schemas never mix. |
TurnProbes + DLQ |
FrontDoor, TurnExecutor, TurnProbe | TurnProbe | DelaySeconds up to 900 s; every probe is self-checking so no cancel is needed. |
ComputerStartJobs + DLQ |
TurnExecutor (park), TurnProbe (action unclaimed too long) | ComputerStarter | Message {tenant_id, computer_id, action_id}. |
EventBridge Scheduler (per-turn one-shot schedules, the schedule group, its role and PassRole) is removed from the turn path. Deadlines are SQS delay messages. Reason: Python created, updated and deleted a schedule on every lease renewal; a self-checking delayed message removes all of that and removes a class of IAM. EventBridge Scheduler stays for the budget rollup cron.
2. Data model
2.1 The split, stated once
Pi holds what was said and done. DynamoDB holds who is allowed, what is running, and what is waiting.
| Lives in Pi (the only transcript) | Lives in plain DynamoDB |
|---|---|
| Every message in a bot's view of a channel: human, other bots (attributed), the bot's own replies | Organizations, membership, identity, invitations, org-creation caps |
Model requests and answers, thinking, usage (pi.usage) |
Channels (metadata, participants, roster), bots (names, memory), tasks |
Tool calls and tool results (entries, pi.tool tasks) |
The turn control record (status, attempt, lease, deadline, waiting gate, fence, terminal reason) |
| The per-session channel log (section 2.4) | Turn events (ephemeral progress view, TTL) |
| Compaction (Pi's own) | The mailbox (inbound messages awaiting drain) |
| Grants, approvals, auto-review rules, authorized connections, capability policy | |
| Workers and credentials, computers, computer actions, snapshot metadata | |
| Vendor ledger, budget rollups |
2.2 Why the turn control record is not in Pi
The record must be written by processes that are not the Pi owner and while
no owner is live: the FrontDoor creates it when a message is posted, the
TurnProbe reassigns it when an owner dies, the host result route un-parks it.
Pi allows exactly one writer per storage (fenced). Putting the record in a Pi
document would force every one of those writers to claim a fence first, which
is the owner-free-admission path PI_HARNESS.md left open. A conditional
DynamoDB item is the right tool for compare-and-set control state. It carries
no message text, so it is not a second transcript. The final answer text
exists once, in the Pi session.
2.3 Tables and buckets
| Resource | Contents |
|---|---|
Messaging table (existing, unchanged schema, pk/sk, TTL expires_at) |
All plain items below. Schemas for organizations, membership, identity, invitations, workers, computers, bots, tasks, idempotency, vendor ledger and budget rollups are frozen as they are (the budget port already reads and writes them from TypeScript). |
Conversations table (new, PAY_PER_REQUEST, three LSIs from table-definition.ts) |
Pi session index, pk = PI#<tenant>#<bot>#<channel>. Must be a separate table: LSIs exist only at creation. Removal policy RETAIN outside development. |
PiSessions bucket (new) |
Immutable commit objects. Block public access, SSL enforced, versioning off, no expiry lifecycle on commits/ or segments/. |
ChatticusSnapshots bucket (existing stack) |
Computer snapshot packs, unchanged. |
New or changed items in Messaging (all keys carry the tenant):
| Item | pk / sk | Notes |
|---|---|---|
| Channel meta | {t}#channel#{id} / meta |
As today; next_seq becomes the atomic message-seq allocator (ADD). |
| Channel lookup, roster index, channel idempotency | unchanged | |
| Active-turn and latest-turn pointers | {t}#channel#{id} / active_turn#{bot_id}, latest_turn#{bot_id}, plus active_turn_primary, latest_turn_primary |
The pointer is per (channel, bot): each bot has its own Pi session, so a channel can have several active turns, one per bot. active_turn#{bot_id} is a conditional put, which enforces one active turn per bot per channel. *_primary hold the most recently started active turn and the latest turn across bots, for the unchanged web paths (0.3 change 8). |
| Turn control record | {t}#turn#{id} / meta |
New field set, section 3.1. |
| Turn event | {t}#turn#{id} / evt#{seq:010d} |
Same key shape, TTL 24 h. |
| Turn grant | {t}#turn#{id} / grant |
Unchanged. |
| Mailbox | MB#{tenant}#{bot}#{channel} / {seq:010d} |
New, section 2.5. No TTL (it is a queue, drained and deleted). |
| Computer action | {t}#computer#actions / act#{action_id} and turn index {t}#turn#{id} / act#{call_id} |
New, section 4.4. |
| Fence counter | stored in the Pi table's own OWNER item |
Section 3.2. |
Dropped at cutover (stay in place for 14 days as rollback insurance, then
purged by the follow-up to ticket 40): message items msg#, old-shape turn items, chunk items,
the Python-shape event items. Not read, not written, not migrated:
WAITLIST, CONTACT items.
2.4 Channels, bots and sessions
- A channel is metadata plus the participant list. Participants are humans (one or more) and bots. There is no thread object.
- A session is one Pi storage per (tenant, bot, channel):
storageId = "<tenant>#<bot>#<channel>", root conversation only. A direct channel has one session. A named channel with three bots has three sessions. - Every bot on a channel reads the channel; only the addressed bot acts. In Pi
terms: every committed channel message reaches every participating bot's
session; only the addressed bot's session gets a model run.
- the addressed bot's session receives it as an
inputsubmission; - every other bot's session receives it as a
writeentry (a user-role entry attributed to its author, no model call).
- the addressed bot's session receives it as an
- A bot's own reply is an assistant entry in its own session. At finalization it is mirrored to the other bots' mailboxes as an attributed message.
- Bot memory stays on the bot item and is rendered into each model call as a
Pi
section()(live, not stored in the transcript), exactly the prototype'sbuildSystemPrompt.
The per-session channel log. Each session carries one Pi conversation
document, chatticus.channel-log (scope conversation, history latest),
whose value is an ordered list of
{seq, messageId, authorKind, authorId, addressedToBotId, createdAt, entryId}.
It holds metadata and a pointer to the Pi entry that holds the body; it never
holds message text. It is updated in the same commit as the entry where the
commit is ours (write entries), and by an idempotent reconcile at owner
open where Pi commits the entry internally (input submissions). This is the
cheap, ordered index the message list needs; scanning raw entries would read an
S3 object per tool result.
Prototype needed (ticket 15): confirm the document, the write entry draft
shape and an idempotent reconcile alongside submit({type:"input"}); confirm
a read-only createSession(storage) can read the document and entries while
another owner holds the fence. Fallback if a read-only session is not safe:
the executor publishes the log to a plain DynamoDB item per commit. Do not
adopt the fallback without the prototype failing.
2.5 How a human message enters a session (the mailbox)
Non-owners cannot commit to a Pi storage. So every inbound channel message takes the same single path:
sequenceDiagram
participant W as Web
participant FD as FrontDoor
participant DB as Messaging
participant SQS as TurnRuns
participant EX as TurnExecutor
participant PI as Pi session (addressed bot)
W->>FD: POST /channels/{id}/messages
FD->>DB: ADD next_seq (idempotency key checked first)
FD->>DB: put mailbox item for each bot session
FD->>DB: put turn control record + active_turn#bot pointer (if addressed and that bot has no active turn)
FD->>SQS: run job; probe job (new turn only)
FD-->>W: {message, turn_id}
SQS->>EX: run
EX->>DB: claim turn (CAS), allocate fence
EX->>PI: open; drain mailbox in seq order
Note over EX,PI: addressed message becomes input submission<br/>others become write entries. requestId = msg:{messageId}
EX->>DB: delete drained mailbox items
- A second post addressed to the SAME bot during that bot's active turn
steers. A post addressed to a different bot does not steer: it takes the
ordinary path and starts that bot's own turn immediately (its own
active_turn#{bot_id}conditional put), so several turns can run on one channel at once, each in its own Pi session. The FrontDoor allocatesseqand writes the mailbox item exactly as above, but creates no turn: aTransactWriteputs the mailbox item under a condition that the addressed bot's active turn record (found throughactive_turn#{bot_id}) isactiveand notclosing, and the response carries the existingturn_id. The owner drains the mailbox while the turn runs (at every tool-round boundary seen onwatchEventsand at least every 2 s) and callssubmit({type:"input", whenBusy:"steer", requestId:"msg:"+messageId}). Finalize first setsclosingon the turn record (conditional onattempt_id), then drains once more; any addressed item found is steered in and the turn keeps running, so no steered message is stranded. A post that loses the race againstclosingfails the condition and takes the ordinary new-turn path, which waits for that bot'sactive_turn#{bot_id}pointer to clear and then creates the next turn. requestId = "msg:" + messageIdmakes the drain idempotent (a crash between commit and delete just re-drains to the same submission).- A session that has no turn keeps its mailbox until its next owner opens it. This is the lazy per-session sync: the first thing every owner does is bring the session up to the channel.
- Mailbox items for the same message are written before the turn record, so a claim can never find a prompt message that is not yet durable.
2.6 Serving GET /channels/{id}/messages with integer seq from Pi
- Load channel meta (participants).
- For each participating bot session: read the channel log (document) and list
that bot's mailbox. Filter by
seq > afterbefore fetching any body. - Merge by
seq, dedupe byseq(a human message appears in every session; a bot reply is authoritative in its author's session; a mailbox item is authoritative only until it is drained). - Fetch bodies from the referenced Pi entries (parallel; commit objects are immutable so the FrontDoor keeps an in-memory LRU keyed by object key).
- Return
{messages:[{message_id, channel_id, tenant_id, seq, author_kind, author_id, body, addressed_to_bot_id, created_at}]}.
seq is allocated once, at the moment a message first exists: by the
FrontDoor for human (and API-posted bot) messages, by the executor at turn
completion for the bot's reply. The allocator is one atomic ADD on the
channel item, so order is total across all sessions and gaps (a failed turn
that never allocated) cannot occur. Cost: a long conversation costs one
document read plus body fetches per listing. Mitigation is the LRU, the after
query parameter (reconnect), and the session snapshot objects of ticket 37
(a prerequisite for staging and production, not for development).
2.7 Migration from the current layout
| Environment | Plan |
|---|---|
| development | Free. Wipe Messaging conversation items; deploy fresh. |
| staging, production | Real data. Transcript migration tool (ticket 38), idempotent, online-safe until the flip. |
Tool phases:
- copy (any time before the flip, repeatable, additive; Python keeps
serving): for every channel and every bot participant, claim a fence, replay
the old messages in
seqorder into that session. Human messages become user entries; the session's own bot's messages become assistant entries (a synthetic assistant message: provideropenai, modelmigrated, stopReasonstop, zero usage); other bots' messages become attributed user entries. The channel log is written with the originalseq,message_idandcreated_at; the channel'snext_seqis untouched.requestId = "migrate:" + messageId; aMIGRATED#marker per (channel, session) lets reruns skip. - latest turn per channel is rewritten into the new turn-record shape (status, terminal reason, prompt seq) so reload after the flip still shows a failed turn.
- flip (the final PR): the new FrontDoor starts in
MIGRATING(a state item) and answers every write route with 503 until phase 4 ends. - delta then verify: copy messages with
seqabove the last copied, then verify per channel that the set ofseqvalues and the body hashes equal the old items. Set the state item toOPEN. - rollback until day 14: revert the flip commit and redeploy (the Python tree is in git; old items are untouched). After day 14 the purge follow-up of ticket 40 deletes the old items.
The tool is conversation/bin/migrate-transcripts.ts (library in conversation/src/migration/), every command
taking --environment {development,staging,production} and optional --tenant and --channel:
copy(andcopy --dry-run, which writes nothing and prints, per channel, the old message count, the bots, and how many assistant and attributed entries would be written). Idempotent: a session that already holds every message is not opened and claims no fence. After the writes each session is read back and compared by checksum with the old items. Each session gets aMIGRATED#<tenant>#<bot>#<channel>marker (messages copied, last seq, checksum, phase, time).latest-turnsrewrites turn records additively (the key and every old attribute stay, so the old system still reads them), addsprompt_author_idand the answer'smessage_seq, and writeslatest_turn#{bot}andlatest_turn_primarypointers (never over a pointer the new system wrote). A turn stillactiveis failed withinterrupted_by_transcript_migrationonly while the write gate is closed; otherwise it is reported and left alone.gate close|open|statussets theMIGRATION#write_gatestate item. The HTTP application, given awriteGate, answers every non-read route with 503 and aRetry-Afterwhile it isMIGRATING.delta(gate closed only) copies what was posted since the last copy, converts the turns, then verifies.verifycompares, per channel, what the new read paths serve (the production listing over the sessions) with the old items: counts, seq order, ids, authors, bodies, addressees, timestamps, and the latest turn's status and reason. Exit code 1 on any difference.
Rollback: no old item is deleted or rewritten destructively. Reverting the flip commit serves the old system again;
the MIGRATED# markers, mailbox items and new pointers are inert to it. The purge follow-up removes the old items after
day 14.
Pre-flight (a scripted check in the flip PR): no active turns, TurnJobs
and ComputerTurnJobs empty, no host start in flight.
3. Turn lifecycle on Pi
3.1 The turn control record
{t}#turn#{id} / meta:
turn_id, tenant_id, channel_id, bot_id, status (active|completed|failed|reconciling), prompt_message_seq, attempt_id, attempt (int), claimed_by, lease_expires_at, deadline_at, recovery_attempts, waiting_for, storage_fence, next_event_seq, terminal_reason, message_seq, logical_enqueue_ids. "Waiting" is still active with waiting_for set,
as today.
3.2 Claim and fence
The turn's fence is the Pi storage fence, one number per session:
- Claim (CAS on the turn record): status
active, nowaiting_for, and (no lease or lease expired); setattempt_id,attempt = attempt + 1,lease_expires_at = now + 60 s,deadline_at. Condition failure means a live owner exists: ack the SQS message and stop. This is the Pythonclaim_turn_attemptCAS, ported. - Allocate the Pi fence:
UpdateItemon the session'sOWNERitem,SET fence = if_not_exists(fence, 0) + 1, returning the new value (IndexedStorage.allocateFence, a new static besideclaimOwnership). It is done after the CAS so a duplicate delivery can never raise the fence under a live owner. A stale owner is fenced out by this step, which is the desired effect. - Record
storage_fenceon the turn, openIndexedStoragewith that fence,Harness.open, run. - Renew every 20 s (CAS on
attempt_id). A failed renew or anOwnershipLostmeans this owner is stale: close the harness and return without writing a terminal state (a stalesubmission.wait()never settles, so the executor must close it itself).
CommitOutcomeUnknown is the one fatal storage error: the executor marks the
turn reconciling (3.6) and stops.
3.3 One execution, end to end
flowchart TD
A[run job] --> B[claim CAS + allocate fence]
B -->|lost| Z[ack, stop]
B --> C[open storage + Harness]
C --> D[drain mailbox into the session]
D --> E[submit input requestId turn:id]
E --> F[watchEvents -> coalesce -> turn event items; steer posts drained mid-turn]
F --> G{settled}
G -->|done| H[finalize]
G -->|unanswered model_error| I[fail with member-readable reason]
G -->|tool parked| J[park: turn.waiting, release claim, close harness]
G -->|near Lambda timeout| K[yield: release claim, re-enqueue]
H --> L[alloc seq, channel log, mirror to other mailboxes, turn.completed]
Steering: while the turn runs, the executor also drains mailbox items that
arrived after the first drain (section 2.5) and submits each with
whenBusy: "steer". The turn's single turn_id, SSE stream and committed reply
cover the steered messages too; the reply is one final assistant text (change 3).
runTurn is the prototype's: Harness.open with settings.retry, one root
conversation, root.submit({type:"input", content, requestId:"turn:"+turnId})
(a re-run after a crash returns the same submission; no double prompt),
await submission.wait. Differences from the prototype: the channel arrives
through the mailbox drain (not the front door), and the answer is read from the
settled submission instead of re-parsing.
Finalize (one fenced step, idempotent under a conditional update on
status = active AND attempt_id = mine): allocate the reply seq, append the
channel-log line, write the reply to the other bots' mailboxes, set the turn
completed with message_seq, append turn.completed {message_seq, body},
delete the active_turn#{bot_id} pointer (and active_turn_primary if it names this turn), record spend (3.7).
Failure classification keeps the Python mapping
(worker/model_provider_errors.py): permanent provider errors
(insufficient_quota, 401, 403, 400/404) fail the turn with the readable
reason; temporary ones are Pi's retry policy (maxRetries 2) and, past that,
an SQS retry. The mapping runs on the settled submission's unanswered
reason and detail.
3.4 SSE derived from watchEvents
watchEvents is live-only (a late joiner gets a snapshot, nothing is
replayed), and there is no cross-process live watch. So the executor is the
one watchEvents consumer, and it writes the result as durable turn event items
that the FrontDoor streams. That keeps the invariant (the stream is a view,
re-derivable from the store) and keeps integer seq and Last-Event-ID
trivially.
| Pi event | Turn event kind and fields |
|---|---|
| (FrontDoor at post) | turn.started (seq 1) |
| (executor after claim) | attempt.claimed {attempt_id} |
turn_start |
model.request {attempt_id} |
message_update text deltas |
turn.token {token}; coalesced, flushed about every 250 ms or 200 bytes |
tool_execution_start |
tool.call {body: tool name, action_id: callId} |
tool_execution_end |
tool.result {body, action_id} |
| park | turn.waiting {body: gate, pending_computer_tool} |
| yield, release | attempt.relinquished {attempt_id} |
| finalize | turn.completed {message_seq, body} |
| failure | turn.failed {body: reason} |
| uncertain commit | turn.reconciling {body} |
Thinking deltas are not forwarded. snapshot, inbox_update and the rest are
ignored.
Event writes are TransactWriteItems of [ConditionCheck turn.attempt_id = mine, Put evt#{seq}] (two items), so a stale owner cannot append. The
executor owns seq locally; on a resume it starts at the highest existing seq
plus one (one Query). Events have a 24 h TTL.
The stream route (FrontDoor): authenticate once at open; load the turn;
cursor = Last-Event-ID (a non-numeric value is 400, as today); poll
evt# items after the cursor with backoff 50 ms to 1 s; frames
event: <kind>\nid: <seq>\ndata: <json>\n\n; : heartbeat comment every 15 s;
close after a terminal kind; at 600 s idle with the turn not parked, emit the
synthetic turn.reconciling; end before the Lambda limit and let the client
reconnect. If Last-Event-ID is past the stored events and the turn is
terminal (TTL expired), synthesize the single terminal event from the turn
record. The 15-minute stream limit is already covered by the web's
reconnect logic (EnabledWorkspace.tsx, four retries with backoff).
A new scenario, features/turn_stream_replay.feature, covers reconnect
with Last-Event-ID, because no test covers it today.
3.5 Deadlines and recovery
A probe is an SQS message {tenant_id, turn_id, kind: "deadline", expect_attempt}
delayed by the lease or deadline interval.
| Probe fires, and | Action |
|---|---|
| turn terminal | drop |
| lease valid | re-send itself with the remaining delay |
turn waiting_for set and within the waiting limit (15 min) |
re-send |
| waiting past the limit | fail "computer unavailable" |
| lease expired, attempts left | recovery_attempts + 1, clear claim, enqueue a run (dedupe by logical_enqueue_id(turn, n), ported) |
lease expired, Pi session shows the submission done |
finalize as completed (the owner died after the answer, before finalize) |
| lease expired, none left | fail recovery attempts exhausted |
As built (turn/probes.ts, turn/yield.ts): the probe body is camel case like
the run job, {tenantId, turnId, kind, expectAttempt}. The FrontDoor arms the
first probe (delay 120 s, attempt 0) before it writes the turn record, the
executor arms one at each claim (delay 60 s), recovery and yield arm one for the
next claim, and parking arms one that fires every 60 s until the 15 minute
waiting limit. A probe for an earlier attempt than the turn is on is dropped
while the turn is owned or waiting, but still acts once the lease has run out, so
a crash between a claim and its probe cannot strand a turn. A turn that no
attempt holds (just recovered, or handed on) is owned until its deadline. When
the recovery budget is spent a probe reads the Pi session without owning it
(pi/submission-inspector.ts); if the prompt's submission is already done it
queues one run that finalizes the turn, otherwise it fails the turn. The
executor consumes a run job through consumeRunJob, which acknowledges the
message only after executeTurn returns, so a crash before that leaves the
message for redelivery. FaultPlan (turn/fault-plan.ts) keeps the Python
crash window names and is read by the FrontDoor, the executor and the probe
handler.
Default lease 60 s, deadline 120 s, one recovery attempt, as Python. Recovery
is a plain resume: the next owner reopens the session; Pi resubmits an
interrupted model call, reruns replay: "safe" tools and gives the model an
"interrupted" result for unsafe ones (PI_HARNESS.md). That makes the Python
"ambiguous provider call" machinery unnecessary.
Ported fault injection (turn_fault_hooks, crash windows) becomes an
executor option faults?: FaultPlan so turn_fault_injection.feature and the
recovery scenarios keep their steps.
3.6 Voice understanding
POST /channels/{id}/voice-messages is a FrontDoor route, unchanged in
contract. The understand-the-user step is a single non-durable model call
(gpt-5-nano, minimal reasoning, JSON), not a Pi turn: no session, no fence.
Inputs: the transcript plus the ten most recent messages from the message list
(2.6, limited to ten after the seq cursor), speaker labelled. Participant
checks run before the paid call; replay by Idempotency-Key returns the earlier
result with degraded:false; a filler-only line posts nothing; any other empty
result posts the heard line (the VOICE.md rules, ported with
voice/understanding.py and features/voice_messages.feature). Spend is
recorded as a ledger row with turn_id = "voice:<uuid>" exactly as now. The
resulting message then takes the ordinary admission path (2.5).
Prototype needed (ticket 22): the pi-ai call shape for a one-shot completion
outside a Harness (or, if awkward, the openai package that pi-ai already
depends on). The model id and prompt are unchanged.
3.7 Spend recording
Each model call is a keyed cost record (chatticus.cost, key entry-<entryId>)
in the Pi session, derived from the assistant entry that answered it and priced
from the versioned price book. At conclude, fail, yield, park and when an owner
resumes a turn, the executor derives the records not yet written and then writes
the turn's ledger row and monthly turn index item as absolute totals summed from
those records. A recovered attempt, a takeover or a retry therefore never
double-counts and never loses a committed call; there are no delta counters on
the turn record. See docs/COST_ACCOUNTING.md section 3.8. Computer-work pausing by spend ceiling is checked when a
computer action is created (4.4), as in Python's prepare_computer_tool.
4. The computer
4.1 What moves, what stays
Status: the table below is the computer image and the host worker. The host
worker now serves customer-account computers only (section 9, decision 3).
Computers in Anthus's own accounts run a Pi owner in the same image instead:
the Lambda TurnExecutor runs Pi until the first computer tool call, which
creates a ledger action, parks the turn and publishes a start job; the
ComputerStarter runs the ChatticusComputerOwner Fargate task; the owner
claims the parked turn, runs Pi's own tools locally, calls the model through the
control plane gateway, publishes the workspace snapshot when content changed and
exits. One owner runs per computer at a time. This is built and rehearsed on
development only; staging and production do not run it yet. See
Pi session handoff.
| Item | Decision |
|---|---|
| Container base | node:22-bookworm-slim plus git ca-certificates: files, git and a shell, no display and no browser (the browser is an optional capability a larger image adds back; see the readiness rule below). Same image runs on Fargate, EC2 and local Docker. No Python in the image. |
| Host worker | Serves customer-account computers. A bundled Node program, node /opt/chatticus/host/host-worker.mjs, from computer/host/ (new workspace package). Replaces python -m chatticus.computer_host_worker; CHATTICUS_ECS_HOST_COMMAND changes with it. |
| Snapshots | `python -m chatticus.snapshot pack |
| Executors | Workspace files, terminal (/bin/sh), Chromium (kept in the repo, absent from the default image); browser profiles; workspace path mapping: ported 1:1. |
| Image push scripts | Unchanged. |
A host never fails to boot for a missing optional capability. Model and workspace
readiness do not wait on the browser; when Xvfb or Chromium is absent the host
reports the browser unavailable. A browse or request_computer_capability call on a computer so
marked answers with a fixed message (the browser capability is not available on this
computer) before any action is recorded or any host is started.
4.2 Why the host does not run Pi
Status: this section describes the customer-account path, which is unchanged. For computers in Anthus's own accounts, a Pi owner does run in the container (built and rehearsed on development; see Pi session handoff). The host-worker design in sections 4.3 to 4.6 below is the customer-account path; each of those sections opens with a status note saying what the own-account owner does instead.
An organization's computer can live in the customer's AWS account
(assume-role, ChatticusOrganizationComputerRole). That account must not hold
write access to Chatticus's transcript store. Today the host already reaches
the control plane only over HTTP plus its own snapshot bucket. For customer
accounts this boundary is kept permanently: the host executes tool actions; the
Lambda owner (Chatticus account) is the only Pi writer. The own-account owner puts Pi and
session credentials in a container that runs model-chosen shell commands, so it
is built to limit the damage: the container holds no model key (calls go through
a gateway with a per-turn token), its task role is empty, its storage
credentials are scoped to one session, and the shell runs as an unprivileged
user with a scrubbed environment.
4.3 Host protocol: 21 routes become 9
Status: these nine routes are the customer-account boundary and stay permanently (section 9, decision 3). Own-account computers do not use them: the owner claims the parked turn directly and runs the tools locally (section 4.4; Pi session handoff).
All under /orgs/{tenant}/host/..., bearer worker token (from
POST /orgs/{tenant}/workers/register, invoke-key bootstrap, as today),
X-Chatticus-Host-User-Id kept. Zod schemas live in a shared
host-protocol/ package imported by both the FrontDoor and the host, so the
wire cannot drift.
| # | Route | Replaces |
|---|---|---|
| 1 | GET /computer |
computer, readiness, stopped read |
| 2 | POST /computer/state {stopped?, capability_ready?, capability_unavailable?} |
stopped, capabilities/{c}/ready; capability_unavailable: "browser" records that the image has no browser (browser_unavailable), which a later ready report or a host start clears |
| 3 | POST /snapshot/hydrated |
same |
| 4 | POST /snapshot/published |
publish (the host packs and uploads, then reports the URI) |
| 5 | POST /actions/claim |
list_active_turns, turn, escalation/ensure, unresolved-actions, attempt-claimed, computer/claim, expire-orphaned: one call returns the next pending action for this org (or none) and a lease |
| 6 | POST /actions/{id}/renew |
lease extension |
| 7 | POST /actions/{id}/result {result | error} |
tool-result, execute-pending, complete |
| 8 | POST /actions/{id}/regate {kind: browse|read|write, target} |
browse/regate, workspace/read/regate, workspace/write/regate, browser-context, capability-policy |
| 9 | POST /heartbeat |
worker heartbeat |
The reduction is possible because the policy decision moves to where the data is: the executor (Lambda) evaluates the grant, ceiling and approval before it creates the action, and writes the allowed envelope (paths, origins, tool, arguments) into the action item. The host enforces the envelope; only a navigation to an unforeseen origin needs route 8. Regate is evaluated against the same policy kernel the executor used.
4.3.1 Disk dirty tracking, the hydrate guard and the live-writer lock
These are data-loss protection, not readiness, and they live on the computer
record (disk_dirty, hydrate_required, live_writer_host_id).
- Dirty. Posting the result of a
write_workspaceorrun_terminalaction (route 7, first answer only) setsdisk_dirtywith an in-place conditional update, so it cannot overwrite a concurrent host-start change. A snapshot publish or a hydrate clears it.relocaterefuses while it is set. Reads andbrowseleave it alone. - Hydrate guard. While
hydrate_requiredis set,POST /actions/claimrefuses (400,ComputerNotHydratedError) to hand out awrite_workspaceorrun_terminalaction, so the host never holds a write it must not run. A write now would land on a disk that the intended host is about to replace. - Live-writer lock (the design was silent; decided minimally). One host
holds
live_writer_host_id, granted by a conditional update that succeeds only when the field is absent or already that host. A new host start generation (claimHostStartGeneration, which only succeeds once the previous lease expired) removes it in the same update, because the host that held it belongs to the wedged generation. The lock is not tied to action claims: the claim path does not acquire it, so a replacement host cannot be refused because a dead host still holds it. Nothing else clears it. - Host loss reconciliation. A record can claim a host (
disk_dirtyset orlive_writer_host_idpresent) after that host died without cleaning up.requestComputerHostStartsettles this before it begins a generation. The host is gone when no worker with thecomputercapability for this computer has a heartbeat insideheartbeatTimeoutSeconds, or none is registered; a host with a fresh heartbeat is never touched. The settle is one conditional update, applied only whilehost_start_generationis unchanged and the record still claims a host. It marks the computer stopped, clears the readiness gates,disk_dirtyand the lock, setshydrate_requiredonly when a snapshot exists to hydrate from, and recordshostLostAtandhostLostGenerationas the audit trail. Computer actions the lost host left in flight are settled by the existingexpireLostComputerActions, which runs before the start request.
4.4 The parked-tool handoff (Lambda owner to computer)
Status: on development, for computers in Anthus's own accounts, the parked call
is no longer claimed by a host over HTTP. The Lambda still creates the action and
parks the turn, but the ComputerStarter runs the owner task and the owner claims
the parked turn (takeOverTurn, same session, new fence) and replays the call
locally with Pi's own tools. The text below remains accurate for customer-account
computers, which keep the host protocol. See
Pi session handoff.
The Pi spike parked a tool by closing the owner and relying on crash replay, which left "A parked" indistinguishable from "B crashed mid-execution". Here the ambiguity is removed with an explicit, durable computer action keyed by the Pi tool call id.
Computer tools (read_workspace, write_workspace, run_terminal, browse,
request_computer_capability) are registered on every owner with identical
schemas and are declared replay: "safe", because their execute is a
lookup-or-park and is idempotent by construction:
execute(args, api):
action = actions.byCall(turn, api.callId)
if action.done: return action.result // the effect already happened on the host
if no action: decide(policy, ceiling, approval)
denied -> return denial result
spend ceiling -> return "computer work paused" result
create action(requested, envelope)
park() // signals the executor; awaits abort; never completes
On park() the executor: sets waiting_for = gate, appends turn.waiting,
enqueues ComputerStartJobs (if the computer's host is not live), releases
the claim and closes the harness without aborting. The pi.tool task stays
at its execute checkpoint; the generation stays waiting on tools.
sequenceDiagram
participant EX as TurnExecutor (owner A)
participant DB as Messaging
participant CS as ComputerStarter
participant H as Host worker
participant FD as FrontDoor
participant EX2 as TurnExecutor (owner B)
EX->>DB: create action (requested) + envelope
EX->>DB: turn waiting_for = workspace; release claim
EX->>CS: ComputerStartJobs (if no live host)
CS->>H: ECS run_task / assume-role
H->>FD: POST /actions/claim
FD-->>H: action + envelope + lease
H->>H: execute tool on the live disk
H->>FD: POST /actions/{id}/result
FD->>DB: action done; turn active, unclaimed
FD->>EX2: TurnRuns run job
EX2->>EX2: claim, new fence, open session
EX2->>EX2: pi.tool resumes; execute finds result
EX2->>EX2: generation continues to the answer
Host crash mid-action: the action is claimed with a lease. A probe finds it
expired. For tools whose envelope says idempotent (reads, browse GET) the
action returns to requested; for the others (write_workspace,
run_terminal) it is completed with the error result "interrupted and may
have partially run", the same message Pi uses for unsafe replay, and the turn
resumes. A computer that never claims an action is probed again (re-enqueue
start) until the waiting limit.
As built (domain/actions.ts, domain/computers.ts, domain/computer-start.ts, pi/computer-tools.ts,
turn/park.ts, http/routes/computers.ts; verified facts in PI_HARNESS.md): the tool's execute only looks the call
up and, when it must park, hands the call to the executor (ComputerToolHandoff.park) and waits to be closed. The
executor, not the tool, creates the action (so the crash windows computer_action, computer_park and computer_start
are real), parks the turn with releaseForWaiting (the probe armed at the claim keeps watching and re-arms itself until
the waiting limit), asks the worker registry for a live host (createTurnJob, assignTurn) and publishes a
ComputerStartJob only when there is none (the job carries the computer's policy, so aws_only reaches the starter),
and finally re-reads the action in case the host answered first. The spend ceiling is checked in execute before the
action exists (refuseIfComputerWorkPaused through computerWorkPauseReason); a paused organization gets the refusal as
the tool result and the turn goes on. A probe that finds a parked turn settles its actions first (settleParkedTurn): a
claimed action whose lease ran out goes back to requested when it is idempotent and is answered "interrupted" when it is
not, an answered action resumes its turn, and an action nobody claimed asks for a host start again. Host routes built
here are routes 5 (claim) and 7 (result) of 4.3; the rest, with the zod schemas, are TS-30. The starter
(handleComputerStartJob) deletes its message on success, as 4.5 says, so the Python scenarios that assert a job
"remains queued" or that the queue Lambda returns a batch item failure are obsolete.
The computer is shared by the whole organization, so actions queue per
computer. There is still no stop_computer. Host stop is the existing
set_computer_stopped through route 2.
4.5 Computer start
Status: for computers in Anthus's own accounts the starter generates an owner id,
mints the model gateway token bound to it, assumes ComputerOwnerScopedRole with
session tags (its static policy reads them) and runs the ChatticusComputerOwner task. The ECS host
start described below is what customer-account computers still use.
ComputerStarter is the old ComputerWorker Lambda without turn logic: it
deduplicates by host_start_generation (the existing lease), assumes the
cross-account role when the organization is in its own account, and calls ECS
run_task (command override from the infra env). It deletes the SQS message on
success and returns an item failure only when it could not start. The host
discovers work by claiming actions on boot, so there is no start-to-turn
coupling.
A start that fails always settles the parked turn; it is never left to redeliver until the dead-letter queue. The
starter classifies every failure. A permanent refusal is one that retrying cannot cure: the organization is not
enabled (suspended, pending or gone), has no AWS home, or has a customer-account home without its cross-account role.
It fails the parked turn at once with a plain reason and the job is done, so there is exactly one attempt (the
refusal is logged as computer_start_refused with a reason_class). The monthly spend ceiling is the third permanent
refusal: it answers the turn's open actions with a denial and resumes the turn. Everything else (an ECS RunTask
failure, an STS or CloudFormation error, a customer stack still being created, a missing image) is transient: the
Lambda reports an item failure and the queue redelivers, but only for COMPUTER_START_MAX_DELIVERIES (3) deliveries,
counted from the record's ApproximateReceiveCount. The last delivery fails the parked turn with "could not be started
after several tries" and finishes the job (logged as computer_start_gave_up), so the queue's own redrive limit of 5
and its dead-letter queue are only a backstop for a starter that cannot even settle the turn. The person sees the reason
as the turn's failure, with the wording in domain/computer-start.ts.
Customer-account provisioning, the CloudFormation template asset
(moved into infra/) and the snapshot bucket naming are ported mechanically.
4.6 Host worker internals (Node)
Status: the host worker now serves customer-account computers. The owner program
for own-account computers lives beside it in computer/host/src/ (owner.ts,
owner-main.ts, owner-deps.ts).
computer/host/src/: main.ts (loop and shutdown: hydrate on boot, run until
the deadline, publish before exit), boot.ts (capability readiness gates),
protocol-client.ts (the nine routes, fetch), executors/{workspace,terminal,chromium}.ts,
browser-profiles.ts, workspace-paths.ts, snapshot/*. ChatticusWorker
no longer exists: the loop is "claim, execute, report".
5. Auth
| Concern | Decision |
|---|---|
Cognito id_token |
aws-jwt-verify (CognitoJwtVerifier, tokenUse: "id"; user pool id and client id from SSM, cached per container). Reason: the AWS-maintained verifier, JWKS caching built in, minimal dependencies. Identity stays email-keyed: the verified, email_verified email claim is the key; sub is never trusted for identity (cognito_jwt.py, fix/oidc-sub-claim-trust). |
| Tests | Generate an RS256 key pair per run (jose), inject its JWKS into the verifier's cache, and mint tokens in the World. No network. Prototype needed (ticket 7) for the exact JWKS-injection call. |
| Membership cache | In-memory, 30 s TTL, bounded size, keyed by (tenant, user). Python's cache lived for the process lifetime, which made a suspend take effect only on a cold start. 30 s bounds suspend and removal; the stream authenticates once at open as today. |
| Principal | Same dependency order as principal.py: bearer present; reject a worker token on a user route; integration-test token; Cognito verify; identity by email; membership for the path tenant; org status ENABLED unless the route is waitlist-safe (no waitlist routes remain, so the marker is deleted); role. X-Tenant-Id is rejected with 400. A structural vitest replaces test_route_principal_coverage.py (every route declares its audience). |
| Worker (host) auth | Unchanged model: register with the invoke key, receive a one-time token, store SHA-256 only, constant-time compare. Hosts only; computerless workers no longer exist as HTTP clients. |
| Operator | Authorization: Bearer <operator key> constant-time compare on /operator/orgs/{t}/{enable,suspend,reinstate}. |
| Invoke key | CloudFront-to-Lambda gate, as today (X-Chatticus-Invoke-Key), except /health. |
| Integration-test auth | Development-only IAM-role session exchange (POST /integration-test/session) and integration bearer tokens, ported from http/integration_test_auth.py and integration_test/sigv4.py; registered only when CHATTICUS_INTEGRATION_TEST_ENABLED, never in production. |
| Secrets | Invoke and operator keys stay injected as today (out of scope); the OpenAI key is fetched from SSM at cold start into OPENAI_API_KEY as in the spike. |
6. Test strategy
6.1 The World
One cucumber-js project (control-plane/, currently conversation/), TypeScript
steps, the existing manifest features/cucumber-features.txt. The World
(ChatticusWorld, extending what is on develop) is built from ports with
exactly one implementation each:
| Port | Implementation in a scenario |
|---|---|
Messaging store, Conversations table, PiSessions bucket |
DynamoDB and S3 on one moto process shared by the whole run (started in BeforeAll, tables and bucket created once per worker process). |
| HTTP | app.request() on the real Hono app, with test Cognito tokens. No sockets. SSE tested by reading the response stream (the stream clock is injectable, as StreamClock is today). |
| Model | A scripted pi-ai provider (features-support/fakes/scripted-provider.ts) driven by steps: reply text, tool call, provider error, slow, kill-after-N-events. |
| Clock, ids | Clock and IdSource ports with a controllable fake, so deadlines and probes advance without sleeping. |
| Queues | In-process queue recorder that the World can pump (When the queue is drained), standing in for SQS delivery only. |
| Host | The real host protocol client driving the real FrontDoor in-process, with a fake executor for tool effects. |
| AWS (ECS, STS, CloudFormation, Cost Explorer, SES-free) | Fakes at the SDK boundary (the budget fakes are the pattern). |
There is deliberately no in-memory store: DynamoDB conditions, transactions
and the fence are the behavior under test, and a second implementation would be
a parallel implementation. Isolation: each scenario gets a unique tenant_id
and storage prefix, so tables are shared and never dropped mid-run (the current
per-scenario CreateTable/DeleteTable is replaced).
6.2 Keeping about 500 scenarios fast
- Shared moto and shared tables; unique tenant per scenario; no per-scenario DDL.
cucumber-js --parallel 4(workers each get their own moto-backed table pair; the run starts one moto server on a fixed port).- Tags:
@indexed(real fencedIndexedStorageon moto, about 30 ms per Pi commit) for fence, crash, handoff, and storage scenarios; all other scenarios use the sameIndexedStoragebut a scripted model that commits few entries. Target: p95 scenario under 1.5 s, full suite under 3 minutes on 4 workers, tracked as a CI number from ticket 3. If the target slips, the lever is Pi'sMemoryStoragefor scenarios that never reopen a session, chosen by tag, not a second store of ours. - Pure helpers (scoring-style functions, URIs, pack format, keys, error maps) are vitest unit tests, per AGENTS.md; behavior stays in Gherkin.
- Structural gates in vitest: route-principal coverage, route-to-feature spec
coverage, ported-features manifest,
cucumber-js --dry-runreports zero undefined and zero ambiguous steps for every manifest feature.
6.3 Mapping the existing step texts
Rule: step text does not change unless the behavior changed. A diff in a
.feature file is then a review signal.
-
Parameters: behave
parseplaceholders become Cucumber expressions ({string},{int},{word}); one custom parameter type ({text}) accepts the unquoted free text some Python steps used. A small converter (ticket 3) prints the Cucumber expression for each Python pattern; humans review the output. -
The Python steps drive an in-process
ControlPlaneor the HTTP test server. TypeScript steps callworld.api(HTTP) for anything that was an HTTP step andworld.services(the same functions the routes call) for anything that was a direct plane call. Neither may reach into a store. -
No giant step file. The shared Python files (
control_plane_steps60 features,capability_policy_steps16,messaging_steps15,organization_steps13) are split by the subject of each step, not by feature, into files of at most about 400 lines (a vitest line-count guard):TS step file Subject steps/org.steps.ts,steps/membership.steps.ts,steps/invitation.steps.ts,steps/me.steps.tsorganizations, members, invitations, /mesteps/principal.steps.ts,steps/operator.steps.tsauth steps/channel.steps.ts,steps/message.steps.ts,steps/mailbox.steps.tschannels, admission, listing steps/turn.steps.ts,steps/stream.steps.ts,steps/recovery.steps.tsturns, SSE, probes steps/model.steps.tsthe scripted provider steps/policy.steps.ts,steps/grant.steps.ts,steps/approval.steps.tscapability policy steps/task.steps.ts,steps/voice.steps.ts,steps/ledger.steps.tstasks, voice, spend steps/worker.steps.ts,steps/computer.steps.ts,steps/action.steps.ts,steps/snapshot.steps.ts,steps/host.steps.tsworkers, computer, host steps/web-harness.steps.tsthe four existing web/test-supportharnesses, now direct imports instead of tsx subprocesses -
The web harnesses that drive the API under test (
membership-ui-harness.ts) point at the in-process app through afetchadapter instead of a base URL. -
New features, written first (AGENTS.md):
pi_session_ownership.feature(fence, lost owner, yield),channel_mailbox_and_log.feature,turn_stream_replay.feature,turn_probes.feature,computer_action_handoff.feature,host_protocol.feature,transcript_migration.feature. -
Behave is removed from a feature when it lands in the manifest, which is the mechanism already on develop.
7. Cutover mechanics
7.1 Phases
- Dormant build (tickets 1 to 38): many small PRs, each green on develop.
Code lands in
control-plane/,host-protocol/,computer/host/. Infra PRs deploy the new resources unrouted:Conversations,PiSessions, the new queues, FrontDoor, executor, probe and starter Lambdas with their own Function URL. CloudFront still points at the Python FrontDoor. - Port per feature: when a feature's TypeScript lands, it is added to
cucumber-features.txtand its Python steps are deleted in the same PR (the behave exclusion is automatic). Shared step files shrink; the last consumer deletes them. - Rehearsal: run the full suite and the black-box acceptance runner against the dormant TypeScript stack deployed in development (real AWS, real Pi table and bucket, real SQS), on the ts Function URL.
- The flip (ticket 40), one PR: CloudFront
/api*origin to the new FrontDoor; producers use the new queues; delete the Python FrontDoor,ComputerWorker,ComputerlessWorker,TurnDeadline, the scheduler group, the Lambda Web Adapter layer, theSseSpikestack, the old queues (after the drained check),python/,behave.ini, the CIpythonjob and the pins; update AGENTS.md "Quality gates", README "What is live today" andCONTRIBUTING_AGENT.md. Run order: development, then staging and production together on the normaldeveloptomainpromotion (the CI/CD in AGENTS.md), with the migration phases in 2.7. - Purge (follow-up of ticket 40,
bin/purge-legacy-items.ts, kind M), 14 days later.
7.2 The risky window and how it is bounded
While a feature is in the manifest, its spec runs only against TypeScript, and production still runs Python. Consequences: Python can regress with no spec watching it; a Gherkin change made in the window is proven only on TypeScript.
Bounds, in order of strength:
- Time. Every ticket after 5 is ordered so the whole phase is one push, with a target of three weeks of wall clock from the first manifest addition to the flip. A burndown (ported scenarios over total) is posted in Kanbus at each milestone.
- Python is frozen. Only outage and security hotfixes. A hotfix PR must
include its Gherkin scenario in the
.featurefile; since the TypeScript side will run that scenario red until it implements the same change, the PR also fixes the TypeScript, and the Python change is verified by the black-box acceptance runner on the real development stack. One hotfix changes both code bases in the same PR, so they cannot diverge. - A spec that never goes dark. Ticket 4 ports the black-box acceptance
runner (
integration_test/) to TypeScript first. It speaks only HTTP, so it exercises Python in production today and TypeScript at the flip. It covers the smoke path: sign in, create bot, channel, post, stream, reload. - Rehearsal on the real stack (7.1 step 3) before any production change, because no in-process test exercises CloudFront, a Function URL, SQS or Lambda (AGENTS.md).
- Reversibility. The flip PR reverts cleanly; old items are retained 14 days; the dormant stack is the same code that was tested.
Accepted risk: a latent Python behavior difference that no scenario encoded and that the TypeScript port silently drops. Mitigation is the port rule "mechanical ports keep the Python file's structure and cite its line ranges", and review by a second agent per PR (AGENTS.md "Pull request review").
8. Ticket breakdown
Conventions. M = mechanical port from the named Python files, no design
decisions left. P = needs a verified prototype snippet first because a
library API is uncertain; the ticket's first commit is a vitest or script that
proves the call and is pasted into the ticket as the reference. N = new,
fully specified here. Every ticket writes or extends its Gherkin first, then
steps, then code. "Features" are the existing features/*.feature that must
pass under cucumber-js when the ticket closes; moving them to
cucumber-features.txt and deleting their exclusive Python steps is part of the
ticket. Paths are under control-plane/src/ unless stated. Any ticket whose
Python source is over about 600 lines is split by file as shown.
Phase 0: foundation
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 1 | Rename workspace and CI | git mv conversation control-plane, package @chatticus/control-plane; add CI job (typecheck, vitest, cucumber-js, --dry-run gate); fix manifest test paths |
daily_budget_rollup stays green |
none | N |
| 2 | Item keys and codecs for frozen items | store/keys.ts, store/codecs/{organization,membership,identity,invitation,worker,computer,bot,task,idempotency}.ts; encode(x): Item, decode(item): X; golden fixtures written by Python then frozen as JSON |
none (vitest contract) | 1 | M (messaging/store.py 2821-2897, 2915-3450) |
| 3 | World v2 | features-support/world.ts, moto.ts (shared server, per-run tables, unique tenant), api.ts (app.request client), clock.ts, queues.ts, converter script for step patterns, parallel config, scenario-time CI number |
daily_budget_rollup on the new World |
1 | N |
| 4 | Acceptance runner and demo CLI | bin/acceptance.ts, bin/chat.ts: black-box HTTP over --environment; SigV4 session exchange; SSE parse |
demo_cli (4) |
1 | M (integration_test/*.py, thin_turn_conversation.py) |
| 5 | Prototype: Hono SSE on a Function URL | lambdas/front-door.ts skeleton plus the CDK construct, deployed to development unrouted; heartbeat, abort on disconnect, 900 s end |
none (live check) | 1 | P |
Phase 1: auth and organizations
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 6 | HTTP skeleton | http/app.ts createApp(deps), http/errors.ts statusFor(error), invoke-key and X-Tenant-Id middleware, GET /health, route-audience coverage guard |
org_path_routing |
3 | M (http/app.py 561-650, 2128-2175) |
| 7 | Cognito verifier and principal | auth/cognito.ts verifyIdToken(token): Claims, auth/principal.ts resolvePrincipal(req): Principal, auth/membership-cache.ts (30 s TTL); test JWKS injection |
cognito_principal, principal, principal_authorization, tenant_isolation |
2, 6 | P (aws-jwt-verify JWKS injection and claims) + M (cognito_jwt.py, http/principal.py) |
| 8 | Org, membership, identity kernel | domain/organizations.ts, domain/membership.ts, domain/identity.ts; roles.ts |
organizations, create_organization (part), me (part) |
2, 7 | M (org_records.py, roles.py, principal.py) |
| 9 | Invitations, caps, signup mode, /me, create org |
domain/invitations.ts, domain/creation-limits.ts, routes GET /me, POST /organizations, POST /orgs/{t}/invitations |
invite_organization, me, organization_creation_caps, create_organization |
8 | M (org_records.py, org_creation_limits.py, signup_mode.py, http/app.py 789-861, 895-923) |
| 10 | Operator and integration auth | auth/operator.ts, auth/integration-test.ts; routes /operator/orgs/{t}/*, /integration-test/session |
operator_organization_api, integration_test_auth |
7 | M (operator_credentials.py, http/integration_test_auth.py, integration_test/sigv4.py) |
| 11 | Spend ceiling | domain/organization-spend.ts; PATCH /orgs/{t}/monthly-aws-spend-ceiling; /me paused fields |
web_spend_ceiling, organization_spend_ceiling (non-computer scenarios) |
8, budget/ on develop |
M (organization_spend.py, http/app.py 969-998) |
| 12 | Members CLI and first-org seed | bin/members.ts |
members_cli, first_org_seed |
8 | M (members/__main__.py) |
Phase 2: Pi core
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 13 | Pi session factory | pi/session.ts openOwnerStorage(storageId): {storage, fence}, IndexedStorage.allocateFence in storage/indexed-storage.ts, error mapping (OwnershipLost, CommitOutcomeUnknown), extension bundle pi/extension.ts (section, tool registry) |
new pi_session_ownership.feature |
3 | N |
| 14 | Bots, channels, roster | domain/bots.ts, domain/channels.ts, routes (bots, channels, `users/{u}/bots |
channels), direct channel id uuid5`, name reservation, idempotency |
canonical_channels, shared_channels, web_create_bot, channel scenarios of messages |
2, 7 |
| 15 | Prototype: channel log, mailbox, read-only session | pi/channel-log.ts (ChannelLogDoc, appendLine, readLog), pi/mailbox.ts (put, list, drain); proves a write entry draft, the document commit, an idempotent reconcile beside submit input, and a read-only createSession reading while another owner holds the fence |
new channel_mailbox_and_log.feature |
13 | P |
| 16 | Message admission and listing | domain/messages.ts postMessage, listMessages (2.5, 2.6), seq allocator, idempotency, participant checks, steer-or-new-turn transaction keyed by (channel, bot) (2.5); routes POST/GET .../messages |
messages (30), turn_attempts (admission part) |
14, 15 | N |
| 17 | Turn control record | domain/turns.ts createTurn, claimTurn, renewTurn, releaseForWaiting, fail, complete; store/turn-events.ts; per-(channel, bot) pointers; routes GET /channels/{id}/turn, turns/latest (both with optional ?bot_id, default the most recently started), GET /turns/{id}, turns/{id}/events |
turn_attempts, bot_turns, cost_class_ranking (turn parts) |
2, 16 | M (store.py 1337-1520, 1544-1632; control_plane.py 3180-3260, 3357-3379) |
| 18 | Prototype and build: executor core | turn/executor.ts executeTurn(job, deps), turn/coalescer.ts, turn/finalize.ts (sets closing, final mailbox drain), mid-turn mailbox steering, lambdas/turn-executor.ts, features-support/fakes/scripted-provider.ts; proves a scripted pi-ai provider, watchEvents cadence, the settled unanswered reasons, CommitOutcomeUnknown handling |
bot_turns, model_provider_failures, canonical_channels (turn parts) |
13, 15, 17 | P |
| 19 | SSE route | http/stream.ts streamTurn, Last-Event-ID, heartbeat, idle, terminal synthesis |
realtime_api, new turn_stream_replay.feature |
5, 17, 18 | M (http/app.py 1960-2084, http/sse.py) + N (replay) |
| 20 | Probes and recovery | turn/probes.ts, lambdas/turn-probe.ts, executor yield, logical-enqueue dedupe, fault plan |
turn_recovery, turn_fault_injection, job_routing (enqueue parts), new turn_probes.feature |
17, 18 | M (turn_recovery.py, deadline/*, turn_fault_*, control_plane.py 3258-3316) + N |
| 21 | Spend recording | ledger/vendor-ledger.ts recordFromPiUsage, ledger/vendor-prices.ts |
vendor_ledger |
18 | M (vendor_ledger.py, vendor_prices.py) |
| 22 | Voice | voice/understanding.ts, POST .../voice-messages |
voice_messages, web_voice_control |
16, 21 | P (one-shot completion call) + M (voice/understanding.py) |
| 23 | Tasks | domain/tasks.ts, Pi tool task, routes users/{u}/tasks, tasks/{id} |
thin_task_item, thin_task_http, web_task_list |
14, 18 | M (thin_task.py, control_plane.py 876-1008) |
Phase 3: policy
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 24 | Policy kernel A | policy/capability-policy.ts, policy/sinks.ts, policy/ceiling.ts |
browser_context_policy, capability_sink_wiring, page_content_authority, prompt_injection_containment, task_authority_grant, v1_security_policy_exclusions |
2 | M (capability_policy.py, capability_sinks.py, ceiling.py) |
| 25 | Policy kernel B | policy/authorization-ceiling.ts, policy/approval-binding.ts, policy/connections.ts, policy/overnight.ts, items for approvals, rules, connections (APPROVAL#, RULE#, CONN#) |
approvals, authorized_connections, delegated_authority, exact_consequential_approval, consequential_binding_control, overnight_gated_action, member_ceiling_sinks |
24 | M (authorization_ceiling.py, approval_binding.py, authorized_connections.py, overnight_gated.py). Python kept this state in memory; it must be durable here. |
| 26 | Grants and the Pi tool gate | policy/turn-grant.ts, PUT /turns/{id}/grant, pi/gate.ts (the gate used by every model tool) |
conversation_turn_grant, turn_grant_widening, web_turn_grant, model_tool_loop_sinks, per_task_run_terminal_grant |
18, 24, 25 | N |
Phase 4: workers and the computer
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 27 | Host registry and credentials | domain/workers.ts, auth/worker-credentials.ts, POST workers/register, heartbeat, cost-class ranking |
worker_registration, worker_credentials, worker_tenant_ownership, cost_class_ranking |
7 | M (control_plane.py 595-793, worker_credentials.py) |
| 28 | Snapshot library | snapshot/{uri,store,s3,pack,host}.ts, bin/snapshot.ts (pack, hydrate) |
computer_snapshot_pack, computer_snapshots, computer_snapshot_dynamo |
1 | M (snapshot/*.py) |
| 29 | Computer record and actions, lookup-or-park | domain/computers.ts, domain/actions.ts, pi/computer-tools.ts, executor park path, resume path; new computer_action_handoff.feature |
computer_continuation_worker, structured_journal_handoff, escalation_failure_recovery, mid_turn_computer_escalation, capability_gated_readiness, computer_affinity, shared_computer, computer_host_start_generation |
18, 26, 27 | P (park, close without abort, resume on a new owner; start from the spike's handoff.ts) |
| 30 | Host protocol | host-protocol/ zod schemas, nine routes in http/host.ts |
customer_computer_host_front_door, computer_host_pull_worker, escalation_approval, new host_protocol.feature |
29 | N |
| 31 | ComputerStarter | computer/host-starter.ts, computer/org-host.ts, lambdas/computer-starter.ts |
computer_ecs_host_starter, single_computer_start, organization_spend_ceiling (computer scenarios) |
29 | M (host_starter.py, organization_computer_host.py, cross_account_assume_role.py, computer_start.py) |
| 32 | Customer account provisioning and self-setup | computer/provisioning.ts, computer/customer-stack.ts, computer/customer-image.ts, snapshot/customer-bucket.ts, POST .../self-setup/cross-account-role; template asset moved to infra/ |
cross_account_provisioning, customer_computer_image, customer_cross_account_role_api, customer_snapshot_bucket, web_organization_signup |
9, 28 | M (cross_account_provisioning.py, customer_computers_*.py, customer_computer_image.py, customer_snapshot_bucket.py) |
| 33 | Host worker: executors | computer/host/src/executors/{workspace,terminal}.ts, workspace-paths.ts, host-action-executor.ts |
computer_host_workspace_executor, agent_terminal |
28, 30 | M (workspace_action_executor.py, terminal_action_executor.py, workspace_paths.py, host_action_executor.py) |
| 34 | Host worker: Chromium, boot, loop, disk lifecycle | executors/chromium.ts, browser-profiles.ts, boot.ts, main.ts, protocol-client.ts, disk-lifecycle.ts |
chromium_host_executor, computer_host_readiness, computer_host_disk_lifecycle, computer_host_workspace_recycle, unsupported_browser_action |
33 | M (chromium_action_executor.py, browser_profiles.py, computer_host_boot.py, computer_host_worker.py, computer_host_disk_lifecycle.py) |
| 35 | Container image | computer/Dockerfile (Node base, bundled host and snapshot programs), entrypoint.sh, compose, test_relocate.sh |
live Fargate smoke on development | 34 | N |
Phase 5: infra, migration, flip
| # | Ticket | Module and interface | Features | Deps | Kind |
|---|---|---|---|---|---|
| 36 | Dormant infra | infra/lib/control-plane-stack.ts: Conversations, PiSessions, three queues with DLQs, FrontDoor (own Function URL), executor, probe, starter, integration-test runner; confirm the budget Lambdas are wired; deploy to development unrouted |
none (live) | 5, 13, 18, 31 | N |
| 37 | Orphan sweeper and session snapshot objects | pi/sweeper.ts (the two-clause orphan rule from PI_HARNESS.md), pi/snapshot-object.ts; required for staging and production, optional for development |
new pi_session_maintenance.feature |
13 | N |
| 38 | Transcript migration tool | bin/migrate-transcripts.ts with copy, latest-turns, delta, verify; marker items |
new transcript_migration.feature |
16, 17, 18 | N |
| 39 | Rehearsal | scripted run of the full suite plus acceptance against the dormant development stack; report posted to Kanbus |
none | 4, 36 and all above | N |
| 40 | The flip | one PR (7.1 step 4), pre-flight script, MIGRATING gate, CloudFront origin switch, deletions (including the Python ops tools wiki_publish and channel_migration, and the web voice client's per-channel queue web/lib/voice-line-delivery.ts, which steering makes redundant), docs |
all remaining | 39 | N |
| There are 40 tickets. The 14-day purge is a scripted follow-up recorded on | |||||
| ticket 40, not a separate ticket. |
Parallelism: after 3, the groups {6 to 12}, {13 to 23}, {24 to 26} and {28} are independent until ticket 26 joins policy to the executor and ticket 29 joins the computer to it. Ticket 28 (snapshots, a leaf) can start any time after 1.
9. Decisions
The product owner settled every question this design raised. None is open.
- Second addressed post during an active turn: steer. It is added to the
running turn through Pi's
whenBusy: "steer". The POST returns the same activeturn_idand the turn stream continues (change 1, sections 2.5 and 3.3). The web client sees no error and no new turn. The voice client's per-channel queue is removed in the same cutover (ticket 40). - Final text only is accepted for the committed message (change 3).
- Customer-account computers never get Pi access. The HTTP host protocol (section 4.3) is the permanent boundary.
wiki_publishandchannel_migrationare dropped. They are not in the port plan or the ticket list and are deleted with the Python (ticket 40).- 14-day retention of the old
Messagingconversation items, then purge (2.7, 7.1). - Membership cache stays at 30 s (section 5).
- Pi experimental risk is accepted. Exact versions are pinned and storage conformance runs in CI.
- Waitlist items stay untouched in
Messagingfor the private repo.
Remaining open questions: none.