prompt-workspace-foundationrun.md02_define/output/spec.mdsustentus audience)Every agent prompt on the platform is a compiled TypeScript template literal —
apps/agent/lib/ai/assistant/prompt.ts is one 3.3 kB string. Changing a single word of the
assistant's instructions is a code change, a @sustentus/services rebuild and a Vercel deploy,
which puts the wording of the product's most visible AI surface behind an engineer and a release
cycle. That blocks Q2-2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
under the Scale the bridge initiative: the agentic layer cannot be validated or tuned at the
pace the objective needs while every correction is a deploy. It also compounds — the eight
register stubs behind this one would each hardcode more prompt text that later has to be
migrated, so the foundation has to land before them, not after.
A platform-scoped prompt workspace stored in MongoDB and shaped as the ICM layers, with a runtime assembler that composes the system prompt per request.
The layers, and who owns each. L0 splits in two: a rails preamble that is code-owned,
always composed in, and never editable (tenant isolation, injection defences, output law), plus a
staff-editable identity/voice file. L1 routing — which persona has which register, and which
contract file it uses — is derived from code and is read-only. L2 is one markdown contract per
register, staff-editable. L3 is staff-editable reference material a register names by path
(tone of voice, caveat language, domain vocabulary). L4 — the live conversation, tool output,
tenant context — is assembled per request and never stored in the workspace.
Storage and versioning. A file is identified by its ICM path (L0/identity.md,
L2/registers/assistant.md, L3/tone-of-voice.md) and carries exactly one piece of mutable
state: a pointer at its published version. A version is an immutable document — body, author,
timestamp — with no update path anywhere in the service layer. Publishing writes a new version
and moves the pointer; rollback re-points at an existing version and writes no new body. Two
collections, not an array of versions on the file: version history is unbounded and belongs in
its own collection.
Assembly. At request time the assembler resolves the published version of each file a register needs and concatenates fixed slots in a fixed order — L0 identity → rails → L2 register contract → L3 references, references in the order the contract names them. That order is today's prompt's own order, which is what makes the seed set reproduce the current instructions byte for byte. Assembly returns both the prompt text and the version set: the map of file path → version id that produced it, plus a stable hash over that map. The assembled text is cached by that hash; because versions are immutable the cache never needs busting. The published-pointer lookup is not cached — one indexed read per request, so a publish takes effect on the very next turn rather than at the end of a TTL.
Validation at publish, not at request. A publish is refused, with a human-readable reason, if the resulting assembled prompt exceeds the ICM band's 8k-token ceiling, if a required section is missing from the file's layer shape, or if the body attempts to override the rails (a documented, deterministic rule set — reserved rails headings and known override phrasings). Below the band's 2k-token floor is a warning, not a refusal: today's prompt is roughly 0.8k tokens, so a hard floor would refuse the seeds themselves.
Seeds and fallback. The repo ships the workspace's current content as markdown seed files next to the assembler, and a migration loads them as the initial published version set. The store overrides seeds wherever a published version exists; where it does not — or where the store is unreachable — the assembler falls back to seeds and logs the degradation. Agents run on seeds, never on nothing.
Consumption. The assistant agent stops importing a prompt constant and takes assembled
instructions from the chat route, which already resolves the tenant it needs. The agent itself
stays in apps/agent/lib/ai/; the workspace store, assembler and seeds live in
packages/services because apps/console (stub 4) edits the same workspace and neither app can
import the other. apps/agent/AGENTS.md gains one line scoping its "no prompt in
packages/services" rule to compiled agent prompts, which is what the rule was written to
prevent.
apps/agent/lib/ai/assistant/prompt.ts instructions string, asserted by a test that
compares against the current text.tenantId on either collection, and no code
path that resolves a workspace file per tenant.prompt-console-management). This run's edit surface is the seed files and the service API.conversation-persistence) owns
threads, messages and run records, and none exist yet; building a competing run document here
would collide with it. This run exposes the version set from the assembler — the stamp is
written by stub 2/5 on the record they own. The stub's rough criterion is met at the seam, not
at the record.packages/services with a scoping amendment to
apps/agent/AGENTS.md, because stub 4 edits the same workspace from apps/console.03_build/output/notes.mdpackages/services/src/shared/prompt-workspace.ts: the isomorphic vocabulary — the three stored
layers (L0/L2/L3), PromptVersionRef, PromptVersionSet, AssembledPrompt. L1 routing and L4
working context are deliberately absent: neither is storable, and naming them here would invite
someone to store them.packages/services/src/ai/workspace/: the pure half. rails.ts (code-owned preamble),
catalogue.ts (L1 routing — which files exist, what shape each keeps, which register reads
which), seeds/ (the repo-shipped bodies), compose.ts (slot order, version-set key and hash),
validate.ts (publish refusals), tokens.ts (the ICM band). No DB, no process.env, so it
exports cleanly from /ai.packages/services/src/db/models/prompt-file.ts + prompt-version.ts: the store. A file is
identity plus one mutable pointer at its live version; versions are write-once documents in their
own collection rather than an array on the file, because version history is unbounded.packages/services/src/db/services/prompt-workspace/: resolution (store-first, seed fallback),
publish, rollback, history and listing. Exported from /server as promptWorkspaceService.packages/services/src/db/migrations/1787961600000-seed-prompt-workspace.ts: the file rows and
their indexes.apps/agent/lib/ai/assistant/agent.ts + app/api/chat/route.ts: the assistant takes
instructions as a parameter and the route assembles them per request.
lib/ai/assistant/prompt.ts is deleted — superseding deletes the superseded.apps/agent/AGENTS.md: the "no prompt in packages/services" rule is scoped to compiled prompt
constants, with the workspace named as the deliberate exception and why.compose.test.ts asserts the seed set composes to
legacy-prompt.fixture.ts, a frozen golden copy of the prompt as it shipped. The seeds were
cut from the real prompt.ts bytes by script, not transcribed.rollback touches only publishedVersionId).immutable: true on every path, plus query middleware that
refuses updateOne/updateMany/findOneAndUpdate/replaceOne, and no service method that
mutates one. See the testing note below.composePromptText
puts them there; publishes carrying a reserved rails heading or an override phrasing are
refused (validate.test.ts).resolveBodies never throws). See
the testing note below.tenantId on either collection and no per-tenant resolution path..md files on disk. TSUP does not copy
non-TS assets into dist/ and Next.js does not trace them into the serverless bundle, so a
readFileSync seed would work in dev and throw in production — the exact failure the fallback
exists to prevent. The bodies are still markdown and still one module per workspace file.CONVENTIONS.md → Testing forbids improvising the integration tier. Worth a look in
review, and worth exercising by hand on the preview.main via db-migrate.yaml.apps/agent/AGENTS.md changes in this PR. A convention edit riding with the change that
motivates it — worth reading as part of the review rather than skimming as docs.labels job projects every run folder under .icm/runs/ onto the PR being pushed, so #917 was
left wearing stage:ship persona:csm persona:vendor — another run's labels. Re-running
project-labels.sh prompt-workspace-foundation --stage build --pr 917 repairs it. Parked as
.icm/intake/triage/pipeline-labels-job-clobbers-pr-labels.md with the job-log evidence and the
archival backlog behind it; not absorbed into this diff. Check the label before trusting the
board on this PR.turbo.json → globalEnv is untouched.Migrate preview database passing on the repaired
migration — the direct proof the collation fix converges. This record commit is .icm-only on
top of it and is re-verified GREEN on the head that merges.{ locale: "en", strength: 2 } collation schemaPlugin stamps on the models,
so the published-pointer lookup on the assistant's hot path could not use them and Mongoose's
autoIndex build collided on the index name. The first attempt corrected it fix-forward, on the
readiness pass's finding that the original migration had already been applied to preview. CI then
failed and showed that premise was wrong: the job log has
seed-prompt-workspace ... State is down — its record had been pruned from the shared preview
database by another branch's migrate run (Removing migration(s) from database: agent-conversation-indexes), and production has never run it, so it had not durably applied
anywhere. The correct fix was therefore to repair the original migration rather than layer a
second one over it, which is what shipped: the indexes are dropped-then-created with
collation, so it converges whether Mongoose built the collated index first or an uncollated one
is still in place. The redundant 1788610000000-prompt-workspace-indexes-collation was deleted.down destroyed published prompt history. up creates three file
rows and no versions; down deleted every promptversions row for those paths — i.e. every
prompt a human had published after it ran. Because the migration turned out not to be applied
anywhere, this could be fixed in place rather than parked: down now removes only the file rows
it created and only while they are still unpublished, and never touches promptversions. Its two
dropIndex calls also stopped swallowing every error and now use the house ABSENT regex.publish() skipped validation entirely for a catalogued file no register composes yet — a
file can land ahead of its register, and that path stored a body with no empty, section or rails
check. It now validates against the body alone when there is no reader.publish() still performs no permission check and takes publishedBy as a caller-supplied
string. Nothing exposes it — the only caller in the repo is the chat route's read path — so there
is nothing to exploit today. prompt-console-management must gate its route on the sustentus
audience and derive publishedBy from the session; its stub already carries that requirement.The shared preview database is not a reliable record of what has been applied: a migrate run from
any branch prunes the records of migrations whose files that branch does not have. So "the
migrate-preview job passed on this PR" does not mean a migration is durably applied, and
packages/services/AGENTS.md's never-edit rule has to be judged on whether the migration has
reached production, not on a green preview job. Reading State is down in the job log is the
check that settles it.