git:20261020.1694e0f to git:20261021.a973953

1 added, 1 removed. Audit A to A.

---
summary: "apps/tedi-runtime scoped rules: the runtime kernel review contract, do.ts containment, and the sandbox invariants that fail silently"
read_when:
- Touching anything under apps/tedi-runtime/
- Changing do.ts, a conversation facet, or the agent turn loop
- Adding a tool, a Worker Loader call site, or anything that handles external text
title: "apps/tedi-runtime agent guide"
---
# apps/tedi-runtime Agent Guide
Root rules live in `/AGENTS.md`. This file owns only what is specific to this
app — the constraints that are load-bearing and expensive to rediscover.
Start with `apps/tedi-runtime/README.md`, `src/do.ts`, and the adjacent module
that owns the behavior being changed. `packages/context-core` owns shared
cognition primitives; workstation leases are additive compute.
## This app is the runtime kernel
Every tedi runs here. `AgentTediDO extends Agent` with native Pi cognitive facets is the one
Agent runtime, and a bug in it is a bug in every tedi at once. Hold changes
to a higher bar than app or UI code, in three specific ways:
- **A `do.ts` diff gets read line by line.** Not the file — the diff. That is
the only review promise that scales here.
- **Split a large change by concern.** Separate commits at minimum, so the
runtime change can be reviewed apart from its callers and its tests.
- **Fewer kernel lines is the goal, not more.** A change that adds surface to
`do.ts` should say in its commit message why it could not live beside it.
## `do.ts` is the least reviewable file in the app
One class with many inline `tool({ ... })` definitions and imports, far larger
than any other file in the app. New runtime surface belongs in a module beside
it, not inside it — as a design habit, not a line-count ceiling. The rest of
the app is well decomposed, and the parent DO's real job — tool gatekeeper and
ledger — is much smaller than its size implies.
- **Do not add a new inline `tool({...})` literal to `do.ts`.** Put it in a
module and import it.
- **Do not attempt a big-bang split.** The parent mediates the scoped `TediComputerWorkspaceDO`
capability, budget, dedup and ledger authority; moving those responsibilities
requires an explicit authority and persistence migration. `conversation-facet.ts` proves the seam works for conversations; it
is not proven for the parent's budget, dedup and ledger mediation.
- Migrating one self-contained domain (browser or email — **not** workspace)
through the existing facet tool proxy is the shape of a real reduction.
## Invariants that fail silently
None of these announce themselves when broken.
- **Every Worker Loader manifest sets `globalOutbound`.** Absence means _inherit
the parent Worker's network_, which is the most permissive option and the one
you get by forgetting. `scripts/lint-loader-sandbox.ts` enforces this repo-wide
and holds the known gaps; `durable-codemode.ts` is this app's call site.
- - **External text goes through `wrapUntrustedInput`** (`src/untrusted-input.ts`).
+ - **External text goes through `wrapUntrustedInput`** (`@tedix/chat-transport/untrusted-input`).
The fence markers are derivable from the source name, so the wrapper
neutralizes them inside the payload — visibly, so an operator reading the
ledger sees the attempt. Never hand-roll the fence at a call site. The in-message
handling instructions were deliberately removed because Azure's injection filter
false-positived on them and the policy moved to `AGENT_RUNTIME_PROMPT`, which
makes the delimiter the only structural boundary left.
- **Code Mode `execute` writes the tedi's live workspace.** `cm-execution-gate.ts`
is the gate: session grants in durable KV with a mandatory TTL, legacy
perpetual entries treated as already expired, approval replay via a one-shot
grant keyed to session plus code hash, and fail-closed on any read error or
malformed entry. Do not add a second path to `execute`.
- **Per-tool earned-delegation checks exist only for a turn that carries an
earned grant.** The envelope on a Home delegation is the entrustment Home
matched at dispatch (`selectDelegationAuthorityEnvelope` in `apps/api`). A
facet tool call on such a turn is evaluated against it
(`src/delegation-authority.ts`) and the verdict is recorded as
`delegation.authority.evaluated` — shadow records `wouldHaveDenied`,
enforce denies. A delegation with **no** envelope was authorized by Home's
dispatch decision (operator-forced, approved, or autonomous); its ceiling is
`supervisedDelegationToolSet`, narrowed by a `capabilityPlan` when present.
Request ceilings are enforced and logged with source `delegation-capabilities`;
they never add executor entitlement. `delegatedTurnAuthority` decides whether
earned authority is evaluated. Do not reintroduce an envelope-less earned
"would have denied" verdict: enforce mode refuses such a run at
dispatch policy and at runtime ingress (403) before any tool call, so that
verdict mirrors no enforcement that can happen and only logs unowned rows.
The default mode stays `shadow`
and `src/delegation-authority.test.ts` pins it so a silent flip fails tests.
- **Observational ledger writes are durable-first, not awaited to the API.**
`step.completed`, `tool.started` / `tool.completed` / `tool.failed` and
`delegation.authority.evaluated` go through `RuntimeEventOutbox.publish`
(`src/runtime-event-outbox.ts`): a DO-storage put, then a background write
with a scheduled redrive. Awaiting each write cost about a second, and several
ran sequentially between two model rounds. Two
things keep this honest and both fail silently if you skip them. (1) Reads
order by `createdAt`, which is stamped when the event is built — never
reorder an event's construction away from the moment it describes. (2)
**A new terminal-event path must call `eventOutbox.flush(runId)` first.**
The kernel reconstructs a delegated child's answer from its `tool.completed`
rows the moment it sees `run.completed`
(`apps/api/src/rpc/routers/kernel/child-run-reads.ts`), so
"`run.completed` is visible" must keep implying "this run's observational
rows are visible". `onLedgerMirror` and `mirrorFailedTurn` already drain.
Mandatory budget accounting, admission and cancellation are NOT in this lane
and stay awaited — do not move an enforcement call into the outbox to make a
stopwatch faster.
- **A non-admitted or sealed object never runs Agent or Pi code.** When stored
runtime admission is not `active`, or `historical_replay_seals` has any row,
the `AgentTediDO` and `PiAgent` constructors return `InertRuntimeDO`
(`src/inert-runtime-do.ts`: `fetch` 423, `alarm` clears itself, no RPC).
Keep that check before `super()` and keep the receiver free of lifecycle
code; runtime admission is permanent, not cutover scaffolding.
- **`MAX_CHAT_STEPS = 40`** (`src/runtime-parent-turns.ts`) is the backstop for an explicitly
governed step ceiling, not a default turn cap. A turn with no positive
`maxIterationsPerTask` has no implicit Pi round ceiling and remains
bounded by wall clock, organization admission, and context safety.
## Validation
`test:run` (`scripts/run-tests.ts`) iterates `src/*.test.ts` with
`bun run <file>`, using `node:assert`, then runs the `src/brain/` bun:test
suites and the email and provider Vitest lanes. Run the individual file you
touched first — the loop stops at the first failure, so one unrelated red test
hides every result after it. `test:related <file>...` runs only the named
files; the pre-push gate passes it the test files whose import graph reaches
the changed files.
`test:workers` runs the isolated native Pi recovery and boundary suites under Vitest/workerd;
`type-check:workers` checks its fixture. Run both when changing Pi lifecycle,
accounting, recovery, or cancellation behavior. The fixture uses local DO storage
and scripted model responses, with no production bindings.
Never use bare `bun test` here; it ignores the Workers aliases.