Files
STCourses/docs/superpowers/plans/2026-10-30-v11-ai.md
T

10 KiB
Raw Blame History

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 v1 sources rows;
  • MCP env and headers encrypted 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 the ai prefix (core migrate() requires unique names across packages).
  • An upgrade test seeds v1 rows (core 0001_init.sql only), 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 with STC_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

  1. Schema vs data migrations. Pure-SQL migrations cover what portable SQL can do: the new table, the new column, and folding v1 sources rows into citations_json (a correlated subquery that runs on both engines). Two upgrades cannot be portable SQL: reading config_json.mentorOff needs engine-specific JSON functions, and encrypting MCP secrets needs STC_SECRET. These run in code, in upgradeAiData(db, box): one transaction, idempotent, recorded in schema_migrations under the name ai_data_v11 (no .sql suffix, so it cannot clash with a file). The settings repo awaits it once (memoised; retried after a failure) before its first read or write, and aiFeature starts it at construction so plaintext does not linger until the first request.
  2. Citations column name. ai_messages.citations_json TEXT NULL (the parent plan asks for "a citations column"; a typed name beats a generic meta_json). v1 sources rows are folded into the assistant row immediately before them (by id order, the order v1 reads in) and then deleted.
  3. 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_off stays reserved for "off everywhere" and is not read. The upgrade copies every mentorOff[courseId] === true and removes the mentorOff key from the user's config_json.
  4. Tool calls on ChatMessage. Contract change in @stc/contracts:
    • ToolCall = { id, name, input };
    • ChatMessage.toolCalls?: ToolCall[] (assistant turn) and ChatMessage.providerData?: unknown (opaque, round-tripped by the provider that produced it);
    • AiProvider.chat may also yield { providerData } once per turn. The assistant loop appends one assistant message per model turn (content = the turn's text, toolCalls, providerData), then one tool message 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_use blocks. The in-memory rawByToolId map goes away, so a provider instance holds no conversation state.
    • OpenAI: assistant toolCalls map to tool_calls (function.arguments = JSON of input) with content = text or null. No providerData.
  5. MCP secrets at rest. In config_json each MCP server keeps its non-secret fields; env and headers move into secretCipher = box.encrypt(JSON.stringify({ env, headers })). The admin GET returns env and headers with 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 (changed STC_SECRET), the server is listed without env/headers and 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/read citations_json, no more sources rows)

  • 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; run migrate(db, { "feature-ai": migrationsDirs }); assert the first assistant row has the citations JSON, the second has NULL, no sources rows remain, and a second migrate applies nothing. Add a feature test that a stored assistant message has citations_json set and no sources row is written.

  • Run: fails (no migrations folder).

  • Implement the SQL (ALTER TABLE ai_messages ADD COLUMN citations_json TEXT; correlated UPDATE picking the next message by id when it is a sources row; DELETE ... WHERE role = 'sources'), export the folder, switch feature.ts to the column, switch AI tests that touch the DB to forEachAiDb.

  • 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 one ai_mentor_prefs row (u1, sample) and a config_json without mentorOff but with byok intact; isMentorOff reads 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 touches config_json.mentorOff.

  • Run: fails.

  • Implement table SQL, upgradeAiData(db, box) (mentor part plus the ai_data_v11 marker), repo ready() memo, mentor methods on the table, getUser().mentorOff from 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 uses mcpServersWithSecrets, 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/headers is encrypted by the upgrade (raw config_json no 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's providerData, then a tool message per call; Anthropic replays providerData blocks verbatim (thinking signature intact) and maps a message without it to text + tool_use blocks; a fresh provider instance replays history it did not produce (no in-memory state); OpenAI maps toolCalls to one assistant message with tool_calls and text content.

  • Run: fails.

  • Implement contract fields, provider mapping, loop change; remove rawByToolId and 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; update settings.ts/index.ts comments.
  • 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.