tofu-web Platform Decisions

tofu-web Platform Decisions #

Classification: Restricted (describes source code structure, infrastructure and auth flows).

Status: proposed. This document fixes the platform decisions for apps/tofu-web — the security model, where preferences live, how layer boundaries are enforced, how the tool surface is bounded, how the app reaches Claude on AWS, and how the dependency set is held stable. §8 records the alternatives that were evaluated and rejected, with the evidence for each. §9 names the sections of the architecture plan these decisions supersede.

1. Decisions #

# Area Decision §
1 Security model Authority is the intersection of the user’s and the caller’s. Document-derived text is tainted and cannot reach a mutating command. Irreversible actions gate where the assistant cannot reach §2
2 Preferences Stay in Clerk user metadata, because the entity nav layout is shared with the old webapp for the length of the handover §3
3 Boundaries eslint-plugin-boundaries for the editor, dependency-cruiser as the CI gate §4
4 Tool surface Bounded by count, with the API’s native tool search in place of a bespoke façade §5
5 Model access Claude Platform on AWS, through @tanstack/ai-anthropic with an injected SigV4 client §6
6 Dependency policy The whole TanStack family in the pnpm catalog, upgraded as one unit, enforced in CI §7

The framework stack, the five layers and their dependency rule, the command concept, the dumb-presenter contract, no persisted client state, bun as runtime and test runner, strict TypeScript, and both layouts are all as the architecture plan specifies. This document does not change them.

2. Security model #

The threat is not a user exceeding their permissions. It is an attacker’s instructions arriving inside a document the assistant reads, and then being executed with the user’s authority.

A permission check cannot distinguish the two. Once the assistant reads a supplier invoice carrying injected text, the request source is an attacker wearing the caller’s credentials, and any argument that rests on “the same function, the same permission check, therefore nothing a human could not do” is reasoning about capability while the actual variable is intent. This is the confused-deputy problem. OWASP moved Excessive Agency from sixth to third in the 2026 LLM Top 10 and published a separate Top 10 for Agentic Applications led by Agent Goal Hijack.

This product has all three legs of the lethal trifecta: private data (client ledgers), untrusted content (invoices, bank statements, supplier email, third-party PDFs), and exfiltration paths (any write, plus outbound sync to Xero and QuickBooks). The mitigations are architectural, not filtering.

  1. Authority is the intersection of the user’s and the caller’s. ctx.caller distinguishes a human click from an agent invocation. They are not interchangeable.
  2. Irreversible commands require a human act in the UI. Publishing to Xero or QuickBooks, deleting an entity, moving money, and changing members or permissions are gated where the assistant cannot reach. It may prepare and propose; it may not complete.
  3. Document-derived text is tainted. No context holds raw invoice or bank-statement text and mutating tools. Strings originating in extracted documents are marked, and refused as arguments to any mutating command unless the plan predates the read. Use plan-then-execute or a dual-LLM split for anything document-derived; the extraction pipeline is already shaped this way, so keep chat out of it. WebMCP’s untrustedContentHint is the vocabulary to adopt.
  4. Reversibility tiers, not a destructive boolean. Reads are not gated at all — gating them manufactures the fatigue that makes real gates worthless. Reversible writes execute, log, and offer undo. Only irreversible actions block. Every reversible command declares its compensating command, the same discipline apply_migration enforces with down_sql.
  5. An optimistic-concurrency token on every mutating command, re-validated at execute. The characteristic agent failure is time-of-check-to-time-of-use: the agent reads state to build an approval preview, the human approves later, and the write lands on state that has moved.
  6. Append-only audit on every execution, carrying actor=human|agent, the caller, the session and the trace id.

The operator channel. Claude Opus 5 supports mid-conversation system messages: append {role: 'system', content} to messages[] rather than editing the top-level system field. It preserves the cached prefix and is the documented prompt-injection-safe operator channel, which is what points 1 and 3 need. It is not available on Claude Sonnet 5, so a model downgrade removes it silently — pin the operator channel to a model that carries it.

One gap to close. @bonsai/mcp-tool’s annotations are { readOnlyHint, destructiveHint }, the MCP specification’s own vocabulary, which MCP tools must keep. Commands use reversibility tiers. Nothing yet maps between them, and untrustedContentHint has no home in createAnnotations. Adding it is a one-line change to a library both consumers already import, and is worth doing before the command set grows.

3. Preferences #

Per-user preferences stay in Clerk user metadata, as the architecture plan specifies. Moving them to a bonsapi resource is attractive on its own terms — §8 records the case — but it cannot be adopted while the two frontends coexist.

The blocker is that the entity nav folder layout is shared state. entitySidebarByOrg lives in the webapp’s unsafeMetadata with its own merge route (apps/webapp/src/app/api/auth/user/metadata/route.ts), its own read path (shared/utils/folder/preferences.ts), its own tests, and a consumer in shared/components/nav/entities/nav-entities.tsx. tofu-web’s domain/preferences.ts already keys by organisation id, in its own words, “as the webapp’s entitySidebarByOrg already does”. If the new console wrote that layout to Postgres while the old webapp kept reading Clerk, a user who reordered folders in one app would see stale folders in the other — throughout exactly the coexistence period the route-by-route handover depends on. The same applies to locale and the tour flags (use-tour-status.tsx).

Two costs come with this choice. They are accepted, not solved:

  • Concurrent writes to the same key lose one change silently. The whole preferences object is rewritten from a read, so two tabs editing one key race. The per-key merge protects different keys from each other, not a key from itself. 7.8 already records this.
  • The size ceiling stays load-bearing. Clerk’s 8 KB limit, a 4 KB guard measuring navLayoutByOrg only, and dismissedHints and defaultSavedViewByOrg unbounded outside it. If the record ever exceeds the ceiling, every later write fails — including the writes that would shrink it.

Mitigate rather than restructure: debounce preference writes through Pacer so a burst of drags is one write, and assert total record size in the guard’s own test so the unbounded fields cannot grow unnoticed. Revisit the move to bonsapi when the webapp stops reading these keys — when the handover completes, not before.

One change is still needed and it is small. The plan has tofu-web writing publicMetadata (backend-only writes) while the webapp writes unsafeMetadata (client-writable), which is two stores unless the backfill in 12 happens: copy unsafe to public once, and have the webapp read the public copy from then on. Budget that webapp read-path change — one module, not a migration.

4. Boundary enforcement #

Imports run one direction only: routes → containers → commands → data → domain. Two mechanisms enforce it, deliberately overlapping.

eslint-plugin-boundaries gives inline feedback in the editor. dependency-cruiser in allowed (deny-by-default) mode is the CI gate, and it is the one that carries the guarantee: it runs on the module graph outside ESLint, so eslint-disable and ignore patterns cannot silence it. Also enable boundaries/no-unknown-dependencies and no-ignored-dependencies.

The gate closes a real hole. The generated bonsapi client is lint-ignored, as generated code should be — but it exports mutation hooks, and a lint-ignored file’s exports can be imported from any layer with no diagnostic at all. The only rule worth having is one a passing build cannot lie about.

5. Tool surface #

  • The tool count is a budget. Selection reliability degrades measurably with it: roughly 78% accuracy at ten tools against 13.6% past a hundred, and one production system exposing 58 tools scored about 0.02 bits above random. Shopify’s published bands from Sidekick: 0–20 clear, 20–50 boundaries blur, 50+ unreasonable. Require an eval before each addition. The MCP allowlist — six read tools drawn from a 169-definition catalog — is this discipline already in the scaffold.
  • Use the API’s tool search. Mark commands defer_loading: true and declare tool_search_tool_bm25_20251119 or the regex variant; the model searches the deferred set itself. The search tool must not be deferred and at least one other tool must stay non-deferred, or the request is rejected. On Bedrock this is available on the InvokeModel path only — see §6.
  • Do not grow the system prompt per tool. Return just-in-time guidance alongside tool results instead: cache-friendly, and free of cross-tool bleed.
  • Never mutate the tool array mid-session. The cached prefix renders toolssystemmessages, so the tool array is first and any change to it invalidates everything after. This is measurable rather than assumed — usage.cache_read_input_tokens pinned at zero across identical-prefix requests means something is silently invalidating.

6. Model access #

The deployment target is AWS, which settles two things.

Claude Platform on AWS, not Amazon Bedrock. These are different products. Platform on AWS is Anthropic-operated — SigV4, AWS IAM, AWS Marketplace billing — with same-day API parity. Bedrock is partner-operated and carries a feature subset. Both give AWS-native billing and IAM; only one costs capability. On Bedrock the MCP connector, server-side refusal fallbacks, web search, web fetch, code execution, Batches, Files, the Models API and inference_geo are unavailable; tool search is InvokeModel-only; and the operator channel of §2 is InvokeModel passthrough that breaks against ARN-versioned models. Bedrock remains defensible if committed spend or procurement requires it, accepting those losses.

@tanstack/ai-anthropic with an injected client — not @tanstack/ai-bedrock. The Bedrock package routes through the Converse API, and its own converse/provider-options.d.ts states that Converse accepts four sampling knobs (temperature, top_p, max_completion_tokens, stop) and silently ignores the rest, tool_choice and reasoning_effort among them. It carries no prompt caching, no thinking configuration and no beta passthrough. It is the correct package for Nova or Llama on Bedrock and the wrong one for Claude.

@tanstack/ai-anthropic carries the full Claude surface and takes an injected client:

export type AnthropicTextAdapterConfig =
  | AnthropicTextConfig
  | { client: AnthropicMessagesClient };

// The adapter calls client.beta.messages.create, so the injected client's own
// request pipeline runs — which is what makes SigV4 signing work.
export interface AnthropicMessagesClient {
  readonly beta: { readonly messages: { readonly create: AnyAnthropicMessagesCreate } };
}

Its own documentation states the intent: the callable is type-erased “because alternative Anthropic clients can depend on a different 0.x release of the Anthropic SDK.” Inject AnthropicAws from @anthropic-ai/aws-sdk (Platform on AWS, bare model ids) or AnthropicBedrockMantle from @anthropic-ai/bedrock-sdk (Bedrock, anthropic.-prefixed ids) through createAnthropicChatWithClient. Keep model ids behind that factory rather than at call sites; the prefix is the only divergence between the two.

Platform on AWS requires both AWS_REGION and ANTHROPIC_AWS_WORKSPACE_ID. Neither has a default, and a missing one throws at client construction rather than on first request.

Models. claude-opus-5 at $5/$25 per MTok is the primary. The current model catalog names claude-opus-4-7, previous-generation at identical pricing, so updating it is free. Use claude-sonnet-5 ($2/$10) or claude-haiku-4-5 ($1/$5) for cheap sub-agent work, noting that Sonnet 5 lacks the operator channel of §2. Thinking is {type: 'adaptive'} and on by default; budget_tokens is removed and returns a 400. Depth is output_config.effort, where xhigh suits agentic work. Assistant prefill is removed.

MCP. @tanstack/ai-mcp is the client. It replaces a hand-written streamable-HTTP implementation — roughly 370 lines of JSON-RPC, transport branching and protocol handling that existed because @bonsai/ai had no client at the time. Keep what is genuinely ours: the per-agent allowlist, the request timeout, the per-user connection cache and the warm-up on mount. The API’s own MCP connector is not an option: it is unavailable on Bedrock, and tofu-external-mcp sits behind Cloudflare Access outside production, so Anthropic’s infrastructure could not reach it regardless.

7. Dependency policy #

The stack is TanStack-first. That is coherent and it reduces the number of mental models a contributor carries. It does not by itself reduce risk — it converts the risk from “will these libraries interoperate” into “can a dozen 0.x packages be held on compatible versions across three apps,” which needs an explicit answer.

The repository has already paid for the absence of one. @tanstack/react-router-ssr-query was declared "latest" in three apps, resolved to three different lockfile entries, and the newest required @tanstack/query-core ≥ 5.102.0 against a workspace resolving 5.100.9. The dev server would not boot. Two of those apps are still on "latest".

Policy: every @tanstack/* package is declared in the pnpm catalog:, one version per entry, and upgraded as a single unit. CI fails on any @tanstack/* dependency declared outside the catalog or as "latest".

The app is also behind, and the AI packages move in lockstep — the family publishes together:

Package Current pin Latest
@tanstack/ai ^0.28.0 0.53.0
@tanstack/ai-react ^0.15.4 0.24.0
@tanstack/ai-anthropic ^0.15.1 0.18.5
@tanstack/ai-gemini 0.15.1 (exact) 0.29.1

@tanstack/react-query is now 5.102.8, so the catalog upgrade clears the constraint that broke the dev server above. @tanstack/ai-orchestration remains unpublished, so the one-agent skeleton behind the server/ai/ seam stays as it is.

8. Alternatives considered #

Each of these was evaluated against the decisions above and rejected. The evidence is recorded so the question does not have to be reopened from scratch.

CopilotKit as the tool runtime #

What it offered. The pattern the command layer implements — one definition that is simultaneously a button, a palette entry and an agent tool — is an established one, and CopilotKit has shipped it since 2024 under an MIT licence: useFrontendTool for mount-scoped registration, runTool for the click-equivalent path, useAgentContext for surface context, useHumanInTheLoop for the confirmation round trip, plus transport, cancellation and streaming. On paper it replaces most of the command infrastructure.

Why rejected. It cannot reach Claude on AWS. Read from the shipped @copilotkit/runtime@1.70.1:

  • AnthropicAdapter accepts an injected client, but its client type is { baseURL, apiKey } and the adapter never calls the client — it lifts a URL and a bearer key and issues its own request. AWS clients authenticate by signing each request, so injection cannot carry SigV4.
  • BedrockAdapter extends LangChainAdapter over ChatBedrockConverse, so it is Converse-only — no tool search. Its credentials are { accessKeyId?, secretAccessKey? } with no session token, which rules out STS credentials and therefore IRSA on EKS, forcing long-lived keys into Doppler. Its default model is a Nova.
  • Every adapter’s source path is src/v1-deprecated/, with a differently-shaped v2 alongside, so a custom adapter would be built against a surface already superseded.

@tanstack/ai-bedrock reaches credentials through fromNodeProviderChain, so IRSA works. That difference is decisive. The runtime also pulls @langchain/community, langchain, openai, groq-sdk and the Vercel AI SDK, which is a large server dependency surface to take on in exchange.

A static Router bundle plus a separate bun service #

What it offered. Removing TanStack Start’s server would have removed a pre-release dependency chain from the request path, keeping only Router’s typed and runtime-validated search params, which are a Router feature and survive independently.

Why rejected. The chain is now shorter than the objection assumed: nitropack is no longer present at @tanstack/react-start@1.168.50. What remains is h3@2.0.1-rc.20 (aliased h3-v2, in start-server-core) and srvx ^0.11.9 (in start-plugin-core). Against that: Start is what the scaffold already runs, sales-portal and nigari already run it in production in this monorepo, and a separate service would need its own Dockerfile, ECR repository, k8s resources and three overlays to host a single streaming route.

The residual doubt is testable rather than arguable, and the plan already defines the test — see §10. Note that upgrading Start is a substrate migration (Nitro to srvx and h3-v2) rather than a version bump: the Dockerfile, build output shape and deployment path all need re-checking.

Zustand for client state #

What it offered. A larger user base and a stable 4.x/5.x line, against @tanstack/store at 0.x.

Why rejected. @tanstack/react-store is already a dependency of @tanstack/react-router, react-form and react-table, and @tanstack/store of pacer. It is in the dependency tree regardless, so using it directly adds nothing, while Zustand adds a package and a second state paradigm. Download-count comparison measures GitHub popularity, not risk, for a library that ships as the reactive substrate under most of this stack.

Effect for the Result type and service wiring #

What it offered. A typed error channel, real dependency injection through Layer, and Schedule/Cache/timeout primitives that would fit the MCP client’s retry and caching behaviour well.

Why rejected. Adopting it would reintroduce exactly the dependency risk §7 exists to prevent: @effect/platform is 0.97.x and @effect/rpc 0.76.x, both 0.x after several hundred releases, while effect core sits at 4.0.0-rc alongside a 3.22.x release line. Effect also expects to own control flow — partial adoption is its worst configuration — for what is, here, a thirty-line type. Independent sources put realistic onboarding at two to four weeks for basics and eight to twelve for production patterns. Reconsider for the AI service alone once 4.0 is stable and @effect/platform reaches 1.x.

Deferring the chat bridge #

What it offered. The bridge was never wired in the scaffold, and a third-party chat runtime with its own thread persistence would have covered conversation continuity, making the bridge redundant.

Why rejected. That rationale depended on adopting CopilotKit. With the tool runtime staying in-family, nothing else covers cross-tab awareness, so the plan’s original decision to keep the bridge stands.

Preferences in a bonsapi resource #

What it offered. Postgres supplies a transaction and a version column, so the storage layer would need no size guard, no distributed lock, no compare-and-swap emulation and no bespoke merge — every difficulty in 6.3 follows from the storage choice rather than from the requirement. The seam would become an ordinary data/queries module, which also resolves the review finding that the preferences source seam was never installed.

Why rejected, for now. It desynchronises shared state during the handover. The entity nav folder layout, the locale and the tour flags are read by the old webapp out of Clerk metadata, with live code on that path; a second store means the two apps disagree about the same user’s sidebar. §3 records the costs of staying, and the condition under which this becomes the right move: when the webapp no longer reads these keys.

Also weighed, and kept as planned #

bonsapi issues the computed permission set per session; the client only reads it — CASL would be a second rule engine against a Rust server and could drift. cmdk, already in @bonsai/ui, for the palette. Router’s validateSearch for search params, which nuqs’ own documentation defers to. The thirty-line Result. The onboarding reducer rather than XState, which is overkill for four to seven linear steps and whose v6 is in alpha.

9. What this supersedes in the plan #

  • 6.6’s safety argument is replaced by §2. The rest of 6.6 — the command concept, the single write path, the surface contract — stands.
  • 6.8’s strictness stack gains the CI gate in §4; the rest is unchanged.

Everything else in the plan is current as written, including 6.3 and 7.7’s preference design and 12’s risk entry for it — §3 keeps that design and states the costs it carries. Also current: §1–2’s assessment and measurements, §3’s precedents, §4’s prototype as corrected, §5’s review-rule mapping, §6.1–6.2’s layers and directory layout, §6.4’s generated client, §6.7’s dumb-UI contract, §6.9 and §7.2’s framework choice, §7.6’s layout specifications, §7.7’s reference feature, §8’s onboarding notes, §9’s workspace notes and §13’s reuse map.

10. Open items #

  1. The bun kill criterion, already specified in 7.5 and not yet run: stream /api/chat under bun, hold the stream through a quiet period beyond ten seconds, abandon a request mid-flight, and check server functions in a bun production build. This is the one open item that can still change a structural decision, and it is roughly an afternoon.
  2. Platform on AWS or Bedrock, per §6. The recommendation is the former on parity grounds.
  3. Whether tool search reaches Claude through Bedrock Mantle, which matters only if Bedrock is chosen. It is InvokeModel-only on Bedrock, and Mantle is a Messages-API endpoint that is neither classic path. Documented as available on Platform on AWS.

11. Two defects found, and the guards added #

Both are fixed on the scaffold branch. They are recorded because they share a shape worth recognising: each one type-checked, passed its tests, and silently returned a default. Test suites cannot catch either, because the fakes return the declared shape and the bug is the absence of a call or a disagreement about one.

The generated client declared an envelope the mutator never returns. bonsapiFetch returns await response.json() — the parsed body — while Orval’s fetch client declared { data, status, headers }, so the types and the runtime disagreed by one level of indexing. Every entity consumer read one hop too deep and got undefined: the list via response.data.data, the detail query via response.data on a bare Entity that has no data field, and createEntity the same way. The nav therefore listed nothing, proving none of the three things 7.5’s definition of done claims for it. Fixed by includeHttpResponseReturnType: false, as both MCP apps already set, plus regeneration and three consumer corrections.

A declared seam was never installed. setPreferencesSource was exported and never called, in app code or tests, so preferencesSource kept its async () => undefined initialiser and every preference read returned DEFAULT_PREFERENCES — theme, dock side, width and folder layout — while the write path saved to Clerk correctly. Fixed by installing it beside its sibling in routes/__root.tsx, with a user.reload() because the client-side Clerk user is itself a cache and runMutation invalidates rather than seeding, so the refetch has to see the write that just landed.

Two guards now exist, and each was verified to fail against the pre-fix tree rather than merely pass against the fixed one:

  • data/seams.test.ts scans data/ for exported set*Source/set*Provider and fails when one is never called outside its own module. Tests are excluded from the installer set deliberately: a seam installed only by its own test is still dead in the product.
  • data/api/client.test.ts asserts the pairing — that the mutator returns the parsed body, that codegen does not declare the envelope, and that no generated file carries an envelope alias. Either half alone is defensible; only the combination is wrong.

12. Provenance #

Verified directly against primary sources: the npm registry for the @tanstack/* and @copilotkit/* families, effect, @effect/*, @modelcontextprotocol/sdk and @clerk/*; the shipped type declarations inside @copilotkit/runtime@1.70.1 and @tanstack/{ai,ai-bedrock,ai-anthropic}; the platform-availability matrix and model reference for the Claude API; and this repository’s own source for the Orval defect, the MCP allowlist, TOOL_EFFECTS and the boundaries configuration.

Resting on secondary extracts, because several source domains were unreachable from the research environment: the CopilotKit Bedrock adapter’s pull-request history, Effect’s adoption and bundle-size commentary, and the tool-count reliability figures in §5.

No proof-of-concept has been built. §10 lists the spikes to run before committing.

13. Implementation status #

Written after the work. It records what the code does rather than what was intended, and it records one reversal: an earlier draft of this document moved preferences to a bonsapi resource, that was built, and §3 then reversed it. The bonsapi slice has been removed.

Landed #

§2, the security model. All six. CommandContext.caller is 'human' | 'palette' | 'agent', and a nested context.run inherits it rather than widening — without that, an agent-initiated command reaches an irreversible one from inside a reversible one and the gate is decorative. reversibility replaces the destructive boolean, and defineCommand refuses a reversible command with no compensate() and an irreversible one with no version(). An irreversible command called by the agent returns requires_human before the confirmation, so the user is never asked to approve something the assistant asked for. domain/taint.ts implements the plan-order rule as §2.3 states it and fails closed: no recorded plan means every taint predates it. version() is read either side of the human gate. The audit is append-only, carries actor, caller, session and trace id, and records no argument values.

One narrowing: version() is required on the irreversible tier, not on “every mutating command”. The race is read → human decides → write; a command with no human gate has no gap to race in, and requiring a token there teaches authors to return a constant.

§3, preferences stay in Clerk metadata, and both mitigations are in:

  • Writes are debounced through Pacer’s AsyncDebouncer (400 ms, trailing). The queue coalesces rather than keeping the newest patch, and that distinction is the point: the write rewrites the whole object from a read, so a plain debounce would have made §3’s accepted cost worse by silently dropping a field changed 200 ms earlier. Per-organisation maps merge per organisation, the same rule the server applies, so a layout saved for one organisation never replaces another’s on the way out either. data/mutations/preferences.test.ts asserts the rule, including that what the queue produces is what the server merge preserves.
  • domain/preferences.test.ts now asserts the total record size, not just navLayoutByOrg: there is a case showing a layout inside its own 4 KB cap inside a record already over Clerk’s 8 KB ceiling. It does not change the guard; it watches the fields the guard does not.

Not done from §3: the unsafeMetadatapublicMetadata backfill and the webapp read-path change. One module, as stated, and it belongs with whoever touches the webapp’s shared/utils/folder/preferences.ts.

§4, the boundary gate. dependency-cruiser in allowed mode, in pnpm check and therefore CI. It does not restate the matrix — it reads .oxlintrc.json and translates it, because two hand-maintained copies of a fifteen-by-fifteen table disagree silently, and data/boundaries.test.ts asserts the translation in both directions. It found two violations the lint could not see: the generated client importing the Orval mutator (now its own data-client element type), and ai/chat.container.tsx classified container while the ai barrel imported it. Verified non-vacuous — a presenter importing a generated mutation hook is reported, and // oxlint-disable silences the lint and changes nothing about the gate. One correction to §4’s wording: the rules are boundaries/no-unknown and no-ignored; there is no no-unknown-dependencies or no-ignored-dependencies in eslint-plugin-boundaries.

§5, the tool surface. commands/budget.test.ts fails at the twenty-first global command and asserts every command carries what a tool search matches on. An earlier draft’s hand-rolled façade was never built.

§6, MCP in-family. server/ai/mcp-client.ts went from 398 lines to about 250: @tanstack/ai-mcp (0.3.9) speaks the streamable-HTTP protocol, and the hand-written JSON-RPC — initialize, the mcp-session-id dance, and a reader handling both an application/json reply and a one-shot text/event-stream — is gone. What stayed is what is ours: the per-agent allowlist, the timeout, the connection cache keyed on user and organisation, and the warm-up. The tests changed shape with it; they used to assert somebody else’s wire format, so they failed on the upgrade rather than on our mistakes.

§7, the dependency policy. Every @tanstack/* package in the workspace declares "catalog:", and tools/local/scripts/check-tanstack-catalog.sh fails CI on any that does not, on a "latest" inside the catalog, and on a package declared but absent from it. It runs inside mise run ts-check and was verified against two planted violations. That swept up more than §7 names: @tanstack/devtools-vite was "latest" in three apps and @tanstack/react-router-ssr-query in two — the #5100 shape, still live. apps/webapp declares twelve more at older versions; it is a named catalog (catalog:webapp) rather than a carve-out in the gate, so the divergence sits in one file instead of across four manifests. The AI family is upgraded per §7’s table, which also clears the query-core >= 5.102.0 constraint. @tanstack/react-start is held at 1.167 deliberately: 1.168 drops Nitro for srvx/h3-v2, a substrate migration rather than a version bump.

§11, both defects. The envelope fix was already present here; the seam install and data/seams.test.ts come from the scaffold branch. Together with data/api/client.test.ts that is the pair of guards §11 describes.

Not done #

§6’s model access on AWS. @tanstack/ai-anthropic is in use but with the default client, not an injected AnthropicAws; AWS_REGION and ANTHROPIC_AWS_WORKSPACE_ID are unwired. This is §10’s first open item and belongs with whoever settles Platform-on-AWS versus Bedrock.

§2’s operator channel. Mid-conversation system messages are not used yet. It is the mechanism for the authority separation §2.1 and §2.3 describe, and it is model-dependent — adopting it means pinning that channel to a model that has it.

§10’s Bun kill criterion. A spike ran two of its four legs (Chat Runtime Spike): a quiet SSE period beyond ten seconds died at 12.0 s on bun defaults and survived with the idleTimeout: 120 that apps/tofu-external-mcp already sets, and ten abandoned requests produced zero log lines. Server functions in a bun production build — the leg that matters most — were not tested.

Unrelated to this document, found while working #

apps/bonsapi/src/model/app/actix_app.rs wraps Cors::permissive() unconditionally, with no environment gate. It reflects any origin with access-control-allow-credentials: true, verified locally against https://evil.example. Severity is bounded by bonsapi authenticating on Authorization: rather than cookies, so a foreign page cannot make the browser attach a token — but reflected-origin plus credentials is one cookie-authenticated endpoint away from being exploitable. It also means the plan’s §12 entry “CORS for console.gotofu.com and the dev hosts” is not an open dependency: every origin is already allowed. Separate change.