10 KiB
STCourses v1.1 — Work package A: deferred AI contract requests 3–5
For agentic workers: executed inline with superpowers:executing-plans and TDD. Steps use checkbox (
- [ ]) syntax. One commit per task.
Goal: settle AI contract requests 3, 4 and 5 (docs/contract-requests/ai.md) and the v1 deferred minor "MCP env/headers in plaintext":
- (3) structured tool calls on
ChatMessage, with the Anthropic and OpenAI providers mapping them natively; - (4) per-course mentor toggles in their own table, migrated from
ai_settings.config_json; - (5) a citations column on
ai_messages, migrated from the v1sourcesrows; - MCP
envandheadersencrypted at rest (same AES-GCM/HKDF box as BYOK keys) and only ever returned masked.
Spec: section 9.2. Parent plan: docs/superpowers/plans/2026-10-30-v1-1.md (Global Constraints, package A).
Global Constraints
- Everything in
CLAUDE.md"Project rules" and the v1.1 Global Constraints applies. - Every DB change is a new portable migration in
packages/feature-ai/migrations/that runs unchanged on SQLite and Postgres, and upgrades a database already on the v1 schema. File names carry theaiprefix (coremigrate()requires unique names across packages). - An upgrade test seeds v1 rows (core
0001_init.sqlonly), runs the AI migrations and the data upgrade, and asserts the result, on SQLite and on Postgres (STC_TEST_PG_URL). - v1 safeguards that must keep holding (each already has tests; they stay green and unchanged in meaning):
- BYOK keys only reach the official endpoints or allowlisted origins, without redirects;
- error bodies from BYOK calls are never echoed to the user;
- stdio MCP processes get PATH, HOME, LANG plus exactly the configured variables;
- the solution guard works on canonical paths;
- the banner text is exactly "Mentor mode off: I'll give full answers";
- tests use fakes only, no network.
- Gates per task:
bun test,bun run typecheck, and the Postgres run withSTC_TEST_PG_URL=postgres://postgres:stc@127.0.0.1:55433/postgres. - Manual check: dev server on port 3493 with
STC_DATA_DIR=/tmp/stc-v11-ai. Never port 3480 or/home/user/STCourses/data.
Design decisions
- Schema vs data migrations. Pure-SQL migrations cover what portable SQL can do: the new table, the new column, and folding v1
sourcesrows intocitations_json(a correlated subquery that runs on both engines). Two upgrades cannot be portable SQL: readingconfig_json.mentorOffneeds engine-specific JSON functions, and encrypting MCP secrets needsSTC_SECRET. These run in code, inupgradeAiData(db, box): one transaction, idempotent, recorded inschema_migrationsunder the nameai_data_v11(no.sqlsuffix, so it cannot clash with a file). The settings repo awaits it once (memoised; retried after a failure) before its first read or write, andaiFeaturestarts it at construction so plaintext does not linger until the first request. - Citations column name.
ai_messages.citations_json TEXT NULL(the parent plan asks for "a citations column"; a typed name beats a genericmeta_json). v1sourcesrows are folded into the assistant row immediately before them (by id order, the order v1 reads in) and then deleted. - Mentor table.
ai_mentor_prefs (user_id, course_id, off INTEGER, updated_at, PK (user_id, course_id)). A row exists only while the mentor is off (turning it back on deletes the row).ai_settings.mentor_offstays reserved for "off everywhere" and is not read. The upgrade copies everymentorOff[courseId] === trueand removes thementorOffkey from the user'sconfig_json. - Tool calls on
ChatMessage. Contract change in@stc/contracts:ToolCall = { id, name, input };ChatMessage.toolCalls?: ToolCall[](assistant turn) andChatMessage.providerData?: unknown(opaque, round-tripped by the provider that produced it);AiProvider.chatmay also yield{ providerData }once per turn. The assistant loop appends one assistant message per model turn (content= the turn's text,toolCalls,providerData), then onetoolmessage per call (toolCallId,name). The v1 one-message-per-call encoding (assistant+toolCallId) is removed.- Anthropic:
providerData = { anthropic: <raw content blocks> }(thinking with signature, text, tool_use). An assistant message carrying it is replayed verbatim; otherwise it maps to text +tool_useblocks. The in-memoryrawByToolIdmap goes away, so a provider instance holds no conversation state. - OpenAI: assistant
toolCallsmap totool_calls(function.arguments= JSON of input) withcontent= text or null. NoproviderData.
- MCP secrets at rest. In
config_jsoneach MCP server keeps its non-secret fields;envandheadersmove intosecretCipher=box.encrypt(JSON.stringify({ env, headers })). The admin GET returnsenvandheaderswith the keys and masked values:••••plus the last 4 characters when the value has at least 12 characters, otherwise••••••••. On save, a posted value equal to the mask of the stored value for the same server id and key keeps the stored value; any other value starting with••••is refused with 400 ("re-enter the value"), so a mask is never stored as a secret. The MCP test endpoint resolves masks the same way. Only server code gets full values (mcpServersWithSecrets()). If the secret cannot be decrypted (changedSTC_SECRET), the server is listed withoutenv/headersand the client starts it without them.
Tasks
Task 1: AI migrations folder, test helper and citations column
Files:
-
Create:
packages/feature-ai/migrations/0001_ai_message_citations.sql -
Modify:
packages/feature-ai/src/index.ts(migrationsDirs),packages/feature-ai/scripts/dev.ts(migrate with the AI folder),packages/feature-ai/src/feature.ts(write/readcitations_json, no moresourcesrows) -
Test:
packages/feature-ai/test/upgrade.test.ts(new),test/helpers.ts(forEachAiDb),test/feature.test.ts -
Write
upgrade.test.ts: on a v1 database (core migrations only) seed a conversation with user, assistant, sources, user, assistant (no sources), and an orphan sources row; runmigrate(db, { "feature-ai": migrationsDirs }); assert the first assistant row has the citations JSON, the second has NULL, nosourcesrows remain, and a second migrate applies nothing. Add a feature test that a stored assistant message hascitations_jsonset and nosourcesrow is written. -
Run: fails (no migrations folder).
-
Implement the SQL (
ALTER TABLE ai_messages ADD COLUMN citations_json TEXT; correlatedUPDATEpicking the next message by id when it is asourcesrow;DELETE ... WHERE role = 'sources'), export the folder, switchfeature.tsto the column, switch AI tests that touch the DB toforEachAiDb. -
Run tests on SQLite and Postgres; typecheck; commit
feat(ai): citations column on ai_messages (contract request 5).
Task 2: Mentor preferences table and the data upgrade runner
Files:
-
Create:
packages/feature-ai/migrations/0002_ai_mentor_prefs.sql,packages/feature-ai/src/upgrade.ts -
Modify:
packages/feature-ai/src/settings.ts,src/feature.ts,src/index.ts -
Test:
test/upgrade.test.ts,test/settings.test.ts -
Tests: v1 user rows with
mentorOff: { sample: true, other: false }plus a BYOK provider become oneai_mentor_prefsrow (u1,sample) and aconfig_jsonwithoutmentorOffbut withbyokintact;isMentorOffreads the table; running the upgrade twice changes nothing; a row with invalid JSON is left alone. Settings test: toggling writes and deletes table rows and never touchesconfig_json.mentorOff. -
Run: fails.
-
Implement table SQL,
upgradeAiData(db, box)(mentor part plus theai_data_v11marker), repoready()memo, mentor methods on the table,getUser().mentorOfffrom the table. -
Gates; commit
feat(ai): per-course mentor preferences table (contract request 4).
Task 3: Encrypt MCP env and headers at rest, masked in the UI
Files:
-
Modify:
src/settings.ts,src/upgrade.ts,src/feature.ts(admin PUT 400 on stale masks, chat usesmcpServersWithSecrets, MCP test route resolves masks),src/web/AdminAiPage.tsx(one-line hint) -
Test:
test/upgrade.test.ts,test/settings.test.ts,test/feature.test.ts -
Tests: v1 server row with plaintext
env/headersis encrypted by the upgrade (rawconfig_jsonno longer contains the values;mcpServersWithSecrets()returns them);getServer()returns masked values only; saving the masked values back keeps the stored secrets; saving a new value replaces it; a stray••••value is refused; the admin config API never contains a secret value; the MCP test route with masked values reaches the server with the real header (fake fetch). -
Run: fails.
-
Implement.
-
Gates; commit
feat(ai): encrypt MCP env and headers at rest and mask them in the admin API.
Task 4: Structured tool calls on ChatMessage
Files:
-
Modify:
packages/contracts/src/ai.ts,packages/contracts/test/types.test.ts,packages/feature-ai/src/assistant/loop.ts,src/providers/anthropic.ts,src/providers/openai.ts -
Test:
test/anthropic.test.ts,test/openai.test.ts,test/assistant.test.ts,test/helpers.ts -
Tests: the loop appends one assistant message with
toolCalls(two calls in one turn) and the provider'sproviderData, then a tool message per call; Anthropic replaysproviderDatablocks verbatim (thinking signature intact) and maps a message without it to text +tool_useblocks; a fresh provider instance replays history it did not produce (no in-memory state); OpenAI mapstoolCallsto one assistant message withtool_callsand text content. -
Run: fails.
-
Implement contract fields, provider mapping, loop change; remove
rawByToolIdand the legacy encoding. -
Gates; commit
feat(ai): structured tool calls on ChatMessage (contract request 3).
Task 5: Docs and manual check
- Mark requests 3–5 settled in
docs/contract-requests/ai.md; updatesettings.ts/index.tscomments. - Dev server on 3493 with
STC_DATA_DIR=/tmp/stc-v11-ai: migrations apply on a fresh DB, admin config shows masked MCP values. - Full gates; commit
docs(ai): contract requests 3-5 settled in v1.1.