Skip to Content

← All archived runs

Run: ai-foundation-hygiene

run.md

Run: ai-foundation-hygiene

  • branch: claude/ai-foundation-hygiene-pipeline-junkrx
  • pr: #913

02_define/output/spec.md

Spec: AI foundation split — a connector in services, agent logic in the agentic app

  • slug: ai-foundation-hygiene
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • touches: packages/services/src/ai, packages/services/src/utils, apps/agent, apps/web/components, .claude/skills/ai-feature, turbo.json
  • complexity: standard

Problem

@sustentus/services/ai currently holds three unrelated things in one namespace: a connector (gateway access, the model constants, defineAgent, generateStructured, the tenant-tool factory), apps/web's product agents (brd/, onboarding/, and the canned/ replay mode they depend on), and the agentic app's assistant (assistant/, whose only consumer is apps/agent). Nothing marks which of the three a new agent should extend.

The next nine stubs in this batch add four persona registers, a runtime prompt workspace, claims and run accounting — all of it agent logic. With no boundary, each one extends whatever shape it finds in that namespace, and the most prominent shape there is brd/, which predates defineAgent and hand-rolls ToolLoopAgent with its own model literal. Nine tickets copying apps/web's 2025 agent into the agentic layer is the failure this stub exists to prevent. The same lack of a boundary already costs on every change: the assistant lives in a shared package, so altering the agentic app's prompt is a @sustentus/services edit that rebuilds for apps/web too.

One gap is a live defect rather than a boundary problem. sanitizeHtml in @sustentus/services/shared is a four-line markdown code-fence stripper — it removes ```html fences and trims, and does nothing else — yet three apps/web surfaces feed its output straight into dangerouslySetInnerHTML. A <script> tag in AI-generated BRD, lead or proposal HTML renders live today, and every register this batch adds will emit HTML onto more surfaces.

Separately, apps/agent's clerkMiddleware is called with no options at all, so it accepts Clerk sessions from any origin under the shared root domain — the exact door apps/console closes with its authorizedParties allowlist.

This is sequence: 1 of 10 and advances Q2-2026 Objective 3 (Validate Technical Infrastructure & Payout Flow) by making the foundation the other nine build on an explicit contract rather than an accident of history.

Proposed change

Draw the boundary, and write it down. @sustentus/services/ai becomes the connector to the AI SDK and provider: gateway access, DEFAULT_AGENT_MODEL / DEEP_EXTRACTION_MODEL, defineAgent, generateStructured, createTenantContext / defineTenantTool / buildTenantTools, and the canned replay mode. Agent logic — agents, prompts, registers, and which tools an agent is given — lives in apps/agent. Tool execution stays in @sustentus/services/server, where Mongoose and the tenant-scoping rules already are, so the boundary keeps the database out of the app without taking tool choice away from it.

Move the assistant into the agentic app. assistant/agent.ts and assistant/prompt.ts move to apps/agent/lib/ai/assistant/, and the assistant exports come off packages/services/src/ai/index.ts. apps/agent's chat route builds its agent from the local module and keeps importing defineAgent, createTenantContext and buildCatalogueTools exactly as it does now; its route test mocks the local module instead of the package. apps/agent is the only consumer of these exports, so nothing else in the monorepo changes. apps/agent/lib/ai/ is where the registers in stubs 5 and 7 will be added.

Freeze apps/web's AI work. brd/, onboarding/, canned/ and scoreExpertFit are not touched: no defineAgent migration, no generateStructured migration, no model-constant rewrite, no import-path churn. They keep their hardcoded model strings and their hand-rolled ToolLoopAgent. They are apps/web's working surfaces and this batch leaves them alone; the skill refresh below marks them legacy so no new work copies them.

Tidy the connector itself, touching nothing apps/web imports: DEEP_EXTRACTION_MODEL is declared in ai/core/agent.ts but missing from ai/core/index.ts, so no consumer can reach it — it joins the export list. ai/config/dev-tools.ts — connector-side dev observability with no current caller — takes its model from DEFAULT_AGENT_MODEL instead of a literal.

Close the sanitizer gap — the one deliberate exception to leaving apps/web alone, because it is a live XSS hole rather than convention drift. A new sanitizeAgentHtml() in packages/services/src/utils/ strips the model's code fences (today's behaviour, kept as an internal first step) and then allowlist-sanitizes with DOMPurify to the TipTap-safe tag set the BRD prompt already constrains output to: p, strong, em, ul, ol, li, h2, h3, a, br with href, target, rel. It ships from the /shared entry so server and client can both call it, via isomorphic-dompurify — already a dependency of apps/web and already used this way in brd-qualification-chat.tsx. All five AI-fed dangerouslySetInnerHTML sites move onto it: the three sanitizeHtml callers in workspace/sections.tsx, service-leads/lead-overview/ and proposals/proposal-detail/, plus the two inline DOMPurify.sanitize calls in brd-qualification-chat.tsx that lose their local allowlist. The old sanitizeHtml export is removed, not left alongside: a non-sanitizer under that name in a shared package is what produced this gap, and deleting it is what stops the next nine stubs reaching for it.

Give the agentic app an origin allowlist. AGENT_AUTHORIZED_PARTIES — comma-separated origins, undefined when unset so per-commit preview hostnames keep working — is parsed in a small apps/agent/lib/authorized-parties.ts and passed to clerkMiddleware as authorizedParties when present. The parser deliberately mirrors apps/console's rather than being shared with it: unifying them would edit a third working app for a ten-line function, and this run is not disturbing working code. The variable is declared in turbo.json's globalEnv.

Write the boundary down where agents will read it. .claude/skills/ai-feature/SKILL.md is rewritten to teach the split — the connector's surface, apps/agent/lib/ai/ as the home of new agents and prompts, tool execution staying in /server, sanitizeAgentHtml as the only way agent HTML reaches the DOM, and brd/ + onboarding/ explicitly marked legacy and not to be copied or extended. apps/agent/AGENTS.md gains the same boundary as a subtree rule, and .claude/SKILLS.md's row is updated to match.

Acceptance criteria

  • packages/services/src/ai/ contains no agent belonging to the agentic app: assistant/ is gone from the package and ai/index.ts no longer exports AssistantAgent, buildAssistantAgent or BuildAssistantAgentConfig.
  • apps/agent/lib/ai/assistant/ holds the assistant's agent and prompt; the chat route builds its agent from there and still passes buildCatalogueTools(createTenantContext(...)), so a tenant-scoped chat turn behaves exactly as before.
  • apps/agent still imports defineAgent, createTenantContext and buildCatalogueTools from @sustentus/services — the move relocates agent logic, it does not fork the connector.
  • apps/web and apps/demo have no diff in this PR except the five sanitizer call sites; in particular packages/services/src/ai/{brd,onboarding,canned}/ and db/services/matching/score-expert-fit.ts are unchanged.
  • DEEP_EXTRACTION_MODEL is exported from ai/core/index.ts and reachable from @sustentus/services/ai; ai/config/dev-tools.ts names no model literal.
  • sanitizeAgentHtml() exists in @sustentus/services/shared, strips model code fences and allowlist-sanitizes to p, strong, em, ul, ol, li, h2, h3, a, br with href, target, rel; it is unit-tested to drop <script>, <iframe>, on* handlers and javascript: hrefs while preserving the allowed tags and the fence-stripping cases the current tests cover.
  • All five AI-fed dangerouslySetInnerHTML sites render through sanitizeAgentHtml, so a <script> tag in agent output is inert on every one of them; the sanitizeHtml export no longer exists and nothing imports it.
  • apps/agent passes authorizedParties to clerkMiddleware from AGENT_AUTHORIZED_PARTIES, refusing sessions from unlisted origins when it is set and falling back to Clerk's default when it is unset; apps/console is unchanged.
  • AGENT_AUTHORIZED_PARTIES is declared in turbo.json's globalEnv.
  • .claude/skills/ai-feature/SKILL.md teaches the connector/agent-logic split, names apps/agent/lib/ai/ as the home of new agents, and marks brd/ and onboarding/ legacy and not to be extended; apps/agent/AGENTS.md carries the same boundary; .claude/SKILLS.md matches. Every code reference in the skill resolves to code that exists.

Out of scope

  • Migrating apps/web's AI work. brd/'s ToolLoopAgent, scoreExpertFit's direct generateObject, and the model literals in both stay exactly as they are, along with onboarding/ and canned/. Deliberately frozen, not overlooked.
  • Moving brd/ or onboarding/ out of packages/services. The connector shares the package with them for now; the boundary is enforced by the documented rule and by where new code goes, not yet by the directory tree.
  • Unifying the authorizedParties parser with apps/console's, and any other change to apps/console.
  • The fail-closed chat route. OQ-7's recorded default stands — the AI Gateway project budget becomes real (an operator action) and the fail-open rate limiter stays. Reversing that answer needs a re-cut before this stub grows the change.
  • The operator actions behind it: creating the agent-chat WAF rule, confirming AI_GATEWAY_API_KEY funding, Clerk production root-domain verification.
  • Any new agent, register, prompt workspace, persistence or accounting — stubs 2–10 — and redirecting apps/web's BRD and onboarding chat into apps/agent, which is the separate redirect batch.
  • packages/ui/src/base/chart.tsx's dangerouslySetInnerHTML: it injects generated CSS custom properties from typed chart config, never model output.
  • Setting AGENT_AUTHORIZED_PARTIES in any Vercel environment — the code reads it; populating it is an operator action, as it is for the console.

Open questions

  • none. OQ-7's default is recorded under Out of scope; the connector/agent-logic boundary and the apps/web freeze were settled with Jamie on 2026-08-28 and are recorded above.

Context budget: over the Inputs table — re-deriving the spec after the architecture was redirected mid-run needed the real consumer graph of packages/services/src/ai (which app imports what), so the assistant, dev-tools and agent chat route were read in source.

03_build/output/notes.md

Build notes: ai-foundation-hygiene

  • commits: dfb1b3c feat — split the AI foundation · bb0d851 fix — type the assistant from the connector's config
  • ci: GREEN on bb0d851 (one RED round first — see Notes for Release)

What changed

  • apps/agent/lib/ai/assistant/{agent,prompt}.ts (moved from packages/services/src/ai/assistant/, git mv so history follows): the agentic app's agent and prompt now live in the app that owns them. agent.ts imports defineAgent from the connector, so the house model default and the single ToolLoopAgent construction are unchanged.
  • packages/services/src/ai/index.ts: the assistant export block is gone, replaced by a comment marking brd/ and onboarding/ legacy and frozen. AssistantAgent — the toolless variant — was deleted rather than moved: it had no consumer anywhere, and CONVENTIONS.md → "when you delete a symbol's last consumer, delete the symbol" applies. buildAssistantAgent() with no arguments reproduces it if it is ever wanted.
  • apps/agent/app/api/chat/route.ts builds its agent from @/lib/ai/assistant/agent and still passes buildCatalogueTools(createTenantContext(context.tenantId)); the tenant-binding comment and the system-message filter are untouched. Its test mocks the local module instead of the package.
  • packages/services/src/ai/core/index.ts: DEEP_EXTRACTION_MODEL added to the export list — it was declared but unreachable. ai/config/dev-tools.ts now takes DEFAULT_AGENT_MODEL instead of a literal.
  • packages/services/src/sanitize/ (new): sanitizeAgentHtml — code-fence strip, then a DOMPurify allowlist — with unit tests written from the acceptance criteria. The old utils/sanitizeHtml.{ts,test.ts} is deleted along with its barrel line.
  • apps/web — the one deliberate exception to leaving the app alone, four files: the three former sanitizeHtml callers (workspace/sections.tsx, service-leads/lead-overview/lead-overview.tsx, proposals/proposal-detail/proposal-detail.tsx) and the two inline DOMPurify.sanitize calls in projects/brd-chat/brd-qualification-chat.tsx, which lose their duplicated local allowlist. Eleven render sites in total now go through one sanitizer.
  • isomorphic-dompurify moved from apps/web to packages/services: apps/web no longer imports it, and CONVENTIONS.md → "when you remove the last import of a dependency, remove the dependency". pnpm-lock.yaml regenerated with pnpm install --lockfile-only.
  • apps/agent/lib/authorized-parties.ts (new) + proxy.ts: AGENT_AUTHORIZED_PARTIES is parsed once at module scope and passed to clerkMiddleware as authorizedParties when set, mirroring apps/console's shape. Declared in turbo.jsonglobalEnv.
  • .claude/skills/ai-feature/SKILL.md rewritten around the split; apps/agent/AGENTS.md's services-boundary section inverted (it previously instructed the opposite — "defining a new agent there rather than in this app"); .claude/SKILLS.md row updated and its now-closed sanitization-gap reference dropped.

Acceptance criteria status

  • No agentic-app agent left in packages/services/src/ai/assistant/ removed, the three exports gone from ai/index.ts.
  • apps/agent/lib/ai/assistant/ holds the agent and prompt; the route builds from there and still passes the tenant-bound catalogue tools.
  • apps/agent still imports defineAgent, createTenantContext and buildCatalogueTools from @sustentus/services — the connector was not forked.
  • apps/web diff is the four sanitizer files plus the package.json dependency move; apps/demo untouched; ai/{brd,onboarding,canned}/ and score-expert-fit.ts unchanged.
  • DEEP_EXTRACTION_MODEL exported and reachable; dev-tools.ts names no model literal.
  • sanitizeAgentHtml() implemented with the specified allowlist and unit-tested against <script>, <iframe>, on* handlers and javascript: hrefs — shipped from @sustentus/services/sanitize, not /shared. See Notes for Release.
  • All AI-fed dangerouslySetInnerHTML sites render through it; the sanitizeHtml export is gone and nothing imports it.
  • apps/agent passes authorizedParties from AGENT_AUTHORIZED_PARTIES; apps/console untouched.
  • AGENT_AUTHORIZED_PARTIES declared in turbo.jsonglobalEnv.
  • Skill, apps/agent/AGENTS.md and .claude/SKILLS.md carry the boundary; every code reference in the skill was checked against the tree.

The CI round

The first push went RED on Vercel – agentic-interface with a type error the move surfaced rather than introduced. The workspace resolves two copies of the AI SDK: packages/services pins zod 3 through the pnpm catalogue, while apps/agent's copy takes the SDK's own zod 4 peer, so @ai-sdk/provider-utils — and with it Tool — is instantiated twice, and the two are not assignable to each other. While buildAssistantAgent lived inside the package, both the agent's tools parameter and buildCatalogueTools' return type came from one copy and it type-checked; moving the agent into the app put them on opposite sides of the split.

Fixed by typing the agent from the connector's own DefineAgentConfig (Pick<DefineAgentConfig, "model" | "tools">) instead of importing Tool from ai in the app, and letting the return type infer. That is the more correct shape anyway — the app should speak the connector's types — but it is a trap every register in stubs 5 and 7 would hit, so the rule is now written into both .claude/skills/ai-feature/SKILL.md and apps/agent/AGENTS.md rather than left for each ticket to rediscover.

Worth knowing at Release: the zod 3/4 skew is real and pre-existing (it shows as unmet-peer warnings on pnpm install), and this run works around it rather than resolving it. A future ticket that bumps the catalogue to zod 4 would remove the hazard.

Notes for Release

  • One deviation from the spec, deliberate. The spec said sanitizeAgentHtml would ship from @sustentus/services/shared. It ships from a new @sustentus/services/sanitize entry point instead. /shared is bundled by tsup into one file that apps/agent/proxy.ts, apps/console/proxy.ts and apps/web's middleware all import — Next middleware, edge runtime — and isomorphic-dompurify is external to that bundle, so a top-level jsdom import would have landed in every one of them. The criterion's substance (one real allowlist sanitizer, callable from server and client, every AI-fed site on it) is met; only the module path differs. The new entry point updates both package.json exports and tsup.config.ts, which is the path the services-imports skill sanctions. Worth a reviewer's eye, since it changes the package's public surface.
  • The zod@4 peer warnings from pnpm install are pre-existing across ai and shadcn; this change neither caused nor fixed them.
  • packages/services/src/ai/tools/catalogue.ts still lives under src/ai/ while being exported from /server. That is correct today (tool execution is server-side by design) but it does mean src/ai/ is not purely client-safe. Not in this run's scope; flagged in case the reviewer expects otherwise.
  • The frozen drifts remain by design and are recorded in the spec's Out of scope: brd/index.ts and score-expert-fit.ts each still carry a hardcoded "anthropic/claude-haiku-4.5", and brd/ still hand-rolls ToolLoopAgent. ToolLoopAgent is therefore constructed in two places, not one.

Release

  • gate: Ready to merge ticked — merge authorised (ticked after Build reported the /sanitize entry-point deviation, so that deviation is accepted rather than outstanding)
  • ci: GREEN on bb0d851 (last code-bearing head) and re-verified by ci-status.sh on the release head immediately before the squash
  • reviews: code medium (complexity: standard; CI Claude review is skippedENABLE_CLAUDE_REVIEW is off — so it was run here, not triaged from comments) · security run — no HIGH or MEDIUM findings introduced · readiness run — safe to merge on the security and deploy axes
  • parked: rich-text-sanitizer-strips-editor-formatting.md
  • docs: technical/packages/services (the /sanitize entry + why it is not in /shared), technical/architecture (the connector/agent-logic split; the assistant no longer lives in the package), technical/deployment (AGENT_AUTHORIZED_PARTIES) · announce: internal

What the reviews changed here

Two findings were fixed on the branch rather than parked, both in-ticket and trivial: .claude/skills/services-imports/SKILL.md advertised sanitizeHtml on a @sustentus/services/utils entry that this run deletes (and which was never in package.json exports at all) — it now carries a real /sanitize row and a corrected /ai row. The readiness pass also caught that the triage stub was still untracked and would have been discarded by the squash; it is committed here.

The one merge-relevant finding — the allowlist stripping formatting the rich-text editor can produce — is a read-view regression, not a stop class, so it is parked per the contract rather than fixed here. Fixing it properly needs a second sanitizer with its own allowlist and a decision about whether <img src> is safe on a field an agent can write; that is spec work, not a Release-stage patch. The stub carries the full detail and names the security question.