docs: v1.1 work package A plan (AI contract requests 3-5, MCP secrets)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fd5c72afc2
commit
fbfe16534a
@@ -0,0 +1,94 @@
|
||||
# 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:[email protected]: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`.
|
||||
Reference in New Issue
Block a user