ai-foundation-hygienerun.md02_define/output/spec.md@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.
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.
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.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.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.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.authorizedParties parser with apps/console's, and any other change to
apps/console.agent-chat WAF rule, confirming
AI_GATEWAY_API_KEY funding, Clerk production root-domain verification.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.AGENT_AUTHORIZED_PARTIES in any Vercel environment — the code reads it; populating it
is an operator action, as it is for the console.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.mddfb1b3c feat — split the AI foundation · bb0d851 fix — type the assistant from the
connector's configbb0d851 (one RED round first — see Notes for Release)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.json → globalEnv..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.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.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.json → globalEnv.apps/agent/AGENTS.md and .claude/SKILLS.md carry the boundary; every code reference
in the skill was checked against the tree.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.
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.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.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./sanitize
entry-point deviation, so that deviation is accepted rather than outstanding)bb0d851 (last code-bearing head) and re-verified by ci-status.sh on the release
head immediately before the squashcomplexity: standard; CI Claude review is skipped — ENABLE_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 axesrich-text-sanitizer-strips-editor-formatting.mdtechnical/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: internalTwo 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.