+
+
+
+```
+
+```ts
+// hugo-apps/src/learning-path/main.ts
+import { createApp } from 'vue'
+import LearningPath from './LearningPath.vue'
+
+const target = document.querySelector('[data-vue-island="learning-path"]')
+if (target) {
+ const goalType = (target.getAttribute('data-goal-type') || 'tutorial') as any
+ createApp(LearningPath, { goalType }).mount(target)
+}
+```
+
+- [ ] **Step 4: Register the Vite entry** — `hugo-apps/vite.config.ts`: add `learning-path` to the rollup input map exactly as `related-graph` is registered (read the file and copy the pattern so it emits `hugo/static/js/learning-path.js`).
+
+- [ ] **Step 5: Add the Hugo mount points**
+
+In `hugo/layouts/tutorials/u1-object-page.html`, near the `related-graph` placeholders/script (lines 408/436/492), add (same `qa`/`previewMode` guard):
+
+```html
+{{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }}
+```
+```html
+{{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }}
+```
+
+In the mission and group layouts, add the same placeholder with `data-goal-type="mission"` / `data-goal-type="group"` plus the island-src script. Locate them with: `git grep -l 'data-vue-island' hugo/layouts` and find the mission/group object templates.
+
+- [ ] **Step 6: Run component test to verify it passes**
+
+Run (from repo root): `npx vitest run --project unit hugo-apps/src/learning-path/LearningPath.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 7: Build the island bundle to confirm it compiles + is hashed**
+
+Run: `npm run build:apps` (or the island build script — check `jq '.scripts' package.json`)
+Expected: emits `learning-path` bundle; island-manifest updated.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add hugo-apps/src/learning-path hugo-apps/vite.config.ts hugo/layouts/tutorials/u1-object-page.html hugo/layouts/**/*mission* hugo/layouts/**/*group*
+git commit -m "feat(kg): 'Your Learning Path' Vue island on tutorial/mission/group pages"
+```
+
+---
+
+### Task 7: MCP tool — `kg_learning_path` (phase 2)
+
+**Files:**
+- Modify: `srv/knowledge-graph-service-mcp.cds` (declare the tool with a JSDoc description)
+- Modify: `srv/knowledge-graph-service.js` (bind `this.on('kg_learning_path', …)` delegating to `learningPath`)
+- Test: `test/unit/kg-learning-path-mcp.test.js`
+
+**Interfaces:**
+- Consumes: the `learningPath` function (Task 5) via `this.send`.
+- Produces: MCP tool `kg_learning_path(goal, goal_type)` returning the same `LearningPathResult`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/kg-learning-path-mcp.test.js
+import { describe, it, expect, beforeAll } from 'vitest'
+import cds from '@sap/cds'
+const NS = 'com.sap.developers.ims'
+let graph
+beforeAll(async () => {
+ await cds.test('serve', '--in-memory', 'srv')
+ graph = await cds.connect.to('KnowledgeGraphService')
+ const { KnowledgeGraphSettings } = cds.entities(NS)
+ await INSERT.into(KnowledgeGraphSettings).entries([{ ID: cds.utils.uuid(), enabled: true, learningPathEnabled: true }])
+})
+describe('kg_learning_path MCP tool', () => {
+ it('delegates to learningPath and returns a result object', async () => {
+ const res = await graph.send('kg_learning_path', { goal: 't-b', goal_type: 'tutorial' })
+ expect(res).toHaveProperty('steps')
+ expect(res).toHaveProperty('goalType', 'tutorial')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/unit/kg-learning-path-mcp.test.js`
+Expected: FAIL — no handler for `kg_learning_path`.
+
+- [ ] **Step 3: Declare the MCP tool** — `srv/knowledge-graph-service-mcp.cds`, inside `extend service KnowledgeGraphService`:
+
+```cds
+ /** Ordered prerequisite chain / "what should I learn next" for a goal.
+ Returns the sequence of tutorials to complete, prerequisites first.
+ @param goal Tutorial or mission/group slug (lowercase). Empty for next-best.
+ @param goal_type 'tutorial' | 'mission' | 'group' | 'next-best'. */
+ function kg_learning_path(goal: String, goal_type: String) returns LearningPathResult;
+```
+
+- [ ] **Step 4: Bind the handler** — `srv/knowledge-graph-service.js`, near the other `kg_*` handlers:
+
+```js
+this.on('kg_learning_path', async (req) => {
+ const goal = (req.data.goal ?? '').toLowerCase()
+ const goalType = req.data.goal_type ?? 'tutorial'
+ try {
+ return await this.send('learningPath', { goal, goalType })
+ } catch (e) {
+ log.error(`kg-service: kg_learning_path(${goalType},${goal}) failed — ${e.message ?? e}`)
+ return { goalType, goal, totalSteps: 0, cyclesBroken: 0, truncated: false, personalized: false, steps: [] }
+ }
+})
+```
+
+- [ ] **Step 5: Verify compile + test passes**
+
+Run: `npx cds compile srv/ 2>&1 | tail && npx vitest run test/unit/kg-learning-path-mcp.test.js`
+Expected: compile OK; test PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/knowledge-graph-service-mcp.cds srv/knowledge-graph-service.js test/unit/kg-learning-path-mcp.test.js
+git commit -m "feat(kg): kg_learning_path MCP tool (delegates to learningPath)"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Core reasoning (closure/subtract/topo-sort/map) → Task 1 ✔
+- Graph adapter (HANA, CQN, non-core dialect) → Task 2 ✔
+- Feature flag (db-setting, dev-only, fail-open, drift test) → Task 3 ✔
+- Endpoint + auth + request-time personalization + privacy invariant → Tasks 4+5 ✔
+- In-product Vue island (tutorial + mission + group; fail-open; login probe) → Task 6 ✔
+- MCP tool (phase 2, same core) → Task 7 ✔
+- Three path shapes (tutorial/mission/group/next-best) → goalType handled in Tasks 1,2,5 ✔
+- YAGNI cuts (no difficulty weighting, standalone next-best page, precompute) → not implemented, per spec ✔
+
+**Type consistency:** `computeLearningPath` I/O (Task 1) matches the handler call (Task 5) and the `graph` snapshot from `assembleLearningPathGraph` (Task 2). `LearningPathResult`/`LearningPathStep` (Task 4) match the handler return and the island's consumed shape (Task 6) and the MCP return (Task 7). Flag key `KG_LEARNING_PATH_ENABLED` / column `learningPathEnabled` consistent across Tasks 3 and 5.
+
+**Open verifications flagged inline (must confirm during implementation, not guesses):** Tutorials key/slug column and Missions/Groups→Tutorials membership association (Task 2); exact `pick/envFlag` idiom + `adminTile` signature (Task 3); `conceptsForUser` served shape slugs-vs-ConceptRef (Task 5); `kg-settings.js` cache reset hook for the flag-off test (Task 5); Vite entry registration + mission/group layout paths (Task 6). Each has a concrete "verify with cds-mcp `search_model` / read the file" instruction at the step.
diff --git a/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md b/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md
new file mode 100644
index 000000000..d40ad4e46
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md
@@ -0,0 +1,1152 @@
+# KTT — Kasimir Teaches TLAs — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a mostly-fun `/explore/ktt/` page: a Duolingo-style, story-driven, animated-Kasimir experience that teaches SAP three-letter acronyms, with progress that reuses the platform's existing completion model.
+
+**Architecture:** A Hugo page under "Explore" mounts a single Vue 3 island (mirroring the `puzzle` island) that runs the whole game. A thin CAP service (`/ktt`) records lesson completions as `TaskRecords` rows (new `KTT_LESSON` task type) so they surface in MyCompletions, syncs local↔HANA progress on login, and returns an optional fail-open AI "banter" line. Lesson content is a git-committed, build-time-generated, human-reviewed JSON data file.
+
+**Tech Stack:** SAP CAP (Node.js, `cds.ql`), SAP HANA Cloud, Vue 3 + Vite (hugo-apps islands), Hugo, `@cap-js/ai`, Vitest (`--project unit`), Playwright (post-deploy e2e).
+
+**Spec:** `docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md`
+
+## Global Constraints
+
+- **Never write raw SQL** — use `cds.ql` / CQL. (SQLite unit tests only; never SELECT a HANA BLOB — N/A here, no BLOBs.)
+- **`@requires` on write paths** — `completeLesson`/`syncProgress` are `@(requires: 'authenticated-user')`; read/`banter` may be `@requires: 'any'`.
+- **Feature flags are DB config, never env** — register in `srv/lib/feature-flags/registry.js` (`kind:'db'`), read with `isFlagEnabled('KTT_ENABLED')`. Default OFF.
+- **SAP facts grounded, not invented** — TLA→expansion values are verified against `sap-devs-server`/`cds-mcp`/official docs and human-reviewed before commit. AI drafts only jokes + distractors.
+- **NGDS autosend excluded for KTT in v1** — do not add a KTT branch to `srv/lib/ngds-client.js`.
+- **`ignore-scripts=true` globally** — new build steps must be explicit entries in `build:all`, never `pre*/post*` hooks.
+- **New persisted entity ⇒ `db/persistence.cds` journal entry**, and run `npx cds deploy --to sqlite::memory:` before committing model changes.
+- **`TaskRecords.taskType` is NVARCHAR** — adding `KTT_LESSON` needs **no** `.hdbmigrationtable` ALTER (comment at `db/schema.cds:191`).
+- **Built island JS is content-hashed** (`/js/ktt-.js`); reference it only via `{{ partial "island-src.html" "ktt" }}`, never a literal path.
+- **hugo-apps `.vue`/`.ts` tests run from repo root** via `npm test` (`vitest run --project unit`); `.vue` tests need `// @vitest-environment happy-dom` as the first line.
+- **Resolve users by `sapId`, not uuid**, when keying test/lookup logic.
+- **srv/lib cp-list audit** — if any new file under `srv/lib/` becomes reachable from `srv/lib/content-store.js`, re-walk `.deploy/mta.yaml`'s `srv-qa` `cp` list. (KTT files are self-contained; expected not reachable — verify in Task 12.)
+
+---
+
+## File Structure
+
+**Backend / model**
+- `db/schema.cds` (modify) — add `KTT_LESSON` to `type TaskType` (`:19`) and `TaskRecords.taskType` (`:195`).
+- `db/ktt.cds` (create) — `KttLessons` catalog entity.
+- `db/persistence.cds` (modify) — journal entry for `KttLessons`.
+- `srv/ktt-service.cds` (create) — `@path:'/ktt'` service: `KttLessons` projection + actions.
+- `srv/ktt-service.js` (create) — handlers: `completeLesson`, `syncProgress`, `banter`, `getKttProgress`.
+- `srv/lib/ktt/completion.js` (create) — pure helper: build the `TaskRecords` entry + idempotency guard (kept out of the `content-store.js` import graph).
+- `srv/lib/ktt/merge.js` (create) — pure local↔remote progress merge (union mastered, max XP/streak).
+- `srv/lib/user-progress.js` (modify) — surface `KTT_LESSON` in MyCompletions.
+- `srv/lib/feature-flags/registry.js` (modify) — register `KTT_ENABLED`.
+
+**Content pipeline**
+- `scripts/generate-ktt-lessons.ts` (create) — build-time generator (facts curated+grounded, jokes/distractors AI-drafted).
+- `scripts/lib/ktt-lessons-schema.ts` (create) — shared TS types + `validateLessons()`.
+- `hugo/data/ktt_lessons.json` (create) — the reviewed content (source of truth).
+- `package.json` (modify) — add `generate-ktt-lessons` npm script + wire into `build:all`.
+
+**Frontend island (`hugo-apps/src/ktt/`)**
+- `main.ts` (create) — mount on `#ktt-mount`.
+- `App.vue` (create) — router-less screen switch (landing / map / lesson / results).
+- `lib/engine.ts` (create) — pure lesson engine (beat sequencing, drill scoring, re-queue, XP/streak).
+- `lib/progress.ts` (create) — localStorage load/save + `mergeProgress`.
+- `lib/server.ts` (create) — CAP client (`fetchLessons`, `completeLesson`, `syncProgress`, `fetchBanter`, `isAuthenticated`).
+- `components/KasimirStage.vue` (create) — rigged SVG Kasimir + mood state machine.
+- `components/SkillTreeMap.vue` (create) — units → lesson nodes.
+- `components/Lesson.vue` (create) — story beats + drills.
+- `components/Results.vue` (create) — streak/XP/mastered.
+- `hugo-apps/vite.config.ts` (modify) — add `ktt` to `rollupOptions.input`.
+- `hugo-apps/src/ktt/__tests__/*.test.ts` (create) — engine, merge, KasimirStage, App.
+
+**Hugo page + nav**
+- `hugo/content/explore/ktt/_index.md` (create) — page front-matter.
+- `hugo/layouts/explore/ktt.html` (create) — SSR shell + island mount.
+- `hugo/data/navigation.yaml` (modify) — Explore menu entry (`:82` group).
+
+**e2e**
+- `test/e2e/ktt.spec.ts` (create) — post-deploy, self-skipping Playwright smoke.
+
+**Deferred (documented, not built in v1):** unit-level rollup (seed each unit as a mission parent + `CompletionPathItems` `KTT_LESSON:` tokens so `completion-rollup.js:159` produces "unit mastered" rows); endless mode; per-acronym `STEP` rows; NGDS autosend. See "Deferred Work" at end.
+
+---
+
+## Phase A — Data model & backend
+
+### Task 1: Add `KTT_LESSON` task type + `KttLessons` catalog entity
+
+**Files:**
+- Modify: `db/schema.cds:19` and `db/schema.cds:195`
+- Create: `db/ktt.cds`
+- Modify: `db/persistence.cds`
+- Test: `test/unit/ktt-model.test.js`
+
+**Interfaces:**
+- Produces: `com.sap.developers.ims.KttLessons { key ID: UUID; legacyId: Integer; slug: String(120); unitId: String(60); title: String(200); order: Integer }`; `TaskRecords.taskType` and `type TaskType` both accept `'KTT_LESSON'`.
+
+- [ ] **Step 1: Write the failing test** — `test/unit/ktt-model.test.js`:
+
+```js
+const cds = require('@sap/cds');
+const { test, expect } = require('vitest');
+
+test('TaskRecords accepts KTT_LESSON and KttLessons entity exists', async () => {
+ const model = await cds.load(['db', 'srv'], { root: process.cwd() });
+ const csn = cds.linked(model);
+ const tr = csn.definitions['com.sap.developers.ims.TaskRecords'];
+ const enumVals = Object.keys(tr.elements.taskType.enum);
+ expect(enumVals).toContain('KTT_LESSON');
+ expect(csn.definitions['com.sap.developers.ims.KttLessons']).toBeDefined();
+ expect(csn.definitions['com.sap.developers.ims.KttLessons'].elements.legacyId).toBeDefined();
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-model.test.js`
+Expected: FAIL (`KttLessons` undefined / enum lacks `KTT_LESSON`).
+
+- [ ] **Step 3: Add the enum values.** In `db/schema.cds`, add `KTT_LESSON;` to the `type TaskType` enum block (near `:19`) and to the `TaskRecords.taskType` inline enum block (near `:195`). Add a trailing comment `// KTT (#KTT) — mostly-fun acronym trainer`.
+
+- [ ] **Step 4: Create `db/ktt.cds`:**
+
+```cds
+namespace com.sap.developers.ims;
+using { com.sap.developers.ims as ims } from './schema';
+
+/** Catalog of KTT lessons — metadata only, so a KTT_LESSON completion
+ row in TaskRecords can join to its title/kind in MyCompletions.
+ Progress lives in TaskRecords, not here. Seeded from hugo/data/ktt_lessons.json. */
+entity KttLessons : cuid {
+ legacyId : Integer @assert.unique; // stable int used as TaskRecords.taskLegacyId
+ slug : String(120) @assert.unique;
+ unitId : String(60);
+ title : String(200);
+ order : Integer default 0;
+}
+```
+
+- [ ] **Step 5: Add the persistence journal entry.** In `db/persistence.cds`, add the `@cds.persistence.journal` annotation for `KttLessons` following the exact pattern of the neighboring entities in that file (mirror the closest `cuid` entity entry).
+
+- [ ] **Step 6: Verify the model deploys**
+
+Run: `npx cds deploy --to sqlite::memory:`
+Expected: deploys with no error.
+
+- [ ] **Step 7: Run test to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-model.test.js`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add db/schema.cds db/ktt.cds db/persistence.cds test/unit/ktt-model.test.js
+git commit -m "feat(ktt): add KTT_LESSON task type and KttLessons catalog entity"
+```
+
+---
+
+### Task 2: `KttService` + `completeLesson` (writes TaskRecords)
+
+**Files:**
+- Create: `srv/ktt-service.cds`, `srv/ktt-service.js`, `srv/lib/ktt/completion.js`
+- Test: `test/unit/ktt-complete.test.js`
+
+**Interfaces:**
+- Consumes: `com.sap.developers.ims.{TaskRecords, KttLessons, Users}` (Task 1); `getNextLegacyId` from `srv/lib/legacy-id.js`; `stampSubmissionId` from `srv/lib/task-record-submission-id.js`.
+- Produces: action `completeLesson(lessonSlug: String, legacyId: Integer, title: String) returns { ok: Boolean; alreadyDone: Boolean }` on service `KttService` at `@path:'/ktt'`; helper `buildKttCompletionEntry({ userId, legacyId, title, attemptNumber })` and `findExistingKttRecord(db, TaskRecords, userId, legacyId)` in `srv/lib/ktt/completion.js`.
+
+- [ ] **Step 1: Write the failing test** — `test/unit/ktt-complete.test.js`:
+
+```js
+const cds = require('@sap/cds');
+const { expect, test, beforeAll } = require('vitest');
+const { GET, POST } = cds.test('serve', '--in-memory', '--project', process.cwd());
+
+let authed;
+beforeAll(() => { authed = { auth: { username: 'alice' } }; });
+
+test('completeLesson writes one KTT_LESSON TaskRecord and is idempotent', async () => {
+ const body = { lessonSlug: 'core-1', legacyId: 90001, title: 'Meet the Platform' };
+ const r1 = await POST('/ktt/completeLesson', body, authed);
+ expect(r1.data.ok).toBe(true);
+ expect(r1.data.alreadyDone).toBe(false);
+ const r2 = await POST('/ktt/completeLesson', body, authed);
+ expect(r2.data.alreadyDone).toBe(true);
+ const db = await cds.connect.to('db');
+ const { TaskRecords } = db.entities('com.sap.developers.ims');
+ const rows = await SELECT.from(TaskRecords).where({ taskLegacyId: 90001, taskType: 'KTT_LESSON' });
+ expect(rows.length).toBe(1);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-complete.test.js`
+Expected: FAIL (`/ktt/completeLesson` 404 — service not defined).
+
+- [ ] **Step 3: Create `srv/ktt-service.cds`:**
+
+```cds
+using { com.sap.developers.ims as ims } from '../db/ktt';
+
+@path: '/ktt'
+@requires: 'any'
+service KttService {
+
+ @readonly entity Lessons as projection on ims.KttLessons;
+
+ @(requires: 'authenticated-user')
+ action completeLesson(lessonSlug: String, legacyId: Integer, title: String)
+ returns { ok: Boolean; alreadyDone: Boolean };
+
+ @(requires: 'authenticated-user')
+ action syncProgress(localJson: LargeString)
+ returns { xp: Integer; streak: Integer; mastered: array of String };
+
+ function banter(context: String) returns String;
+}
+```
+
+- [ ] **Step 4: Create `srv/lib/ktt/completion.js`:**
+
+```js
+import { stampSubmissionId } from '../task-record-submission-id.js';
+
+/** Build a KTT_LESSON TaskRecords entry (does not set ID — CAP default handles it). */
+export function buildKttCompletionEntry({ userId, legacyId, title, attemptNumber = 1, nextLegacyId }) {
+ return stampSubmissionId({
+ user_ID: userId,
+ taskLegacyId: legacyId,
+ taskType: 'KTT_LESSON',
+ status: 'COMPLETED',
+ progress: 100,
+ completionDate: new Date().toISOString(),
+ titleSnapshot: (title || '').slice(0, 255),
+ legacyId: nextLegacyId,
+ attemptNumber,
+ });
+}
+
+/** Idempotency guard tuple (user_ID, taskLegacyId, KTT_LESSON) excluding SUPERSEDED. */
+export async function findExistingKttRecord(TaskRecords, userId, legacyId) {
+ return SELECT.one.from(TaskRecords).where({
+ user_ID: userId, taskLegacyId: legacyId, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' },
+ });
+}
+```
+
+- [ ] **Step 5: Create `srv/ktt-service.js`** (`completeLesson` only; other actions added in later tasks):
+
+```js
+import cds from '@sap/cds';
+import { getNextLegacyId } from './lib/legacy-id.js';
+import { buildKttCompletionEntry, findExistingKttRecord } from './lib/ktt/completion.js';
+
+export default class KttService extends cds.ApplicationService {
+ async init() {
+ const db = await cds.connect.to('db');
+ const { TaskRecords, Users } = cds.entities('com.sap.developers.ims');
+
+ this.on('completeLesson', async (req) => {
+ const { legacyId, title } = req.data;
+ const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id });
+ if (!dbUser) return req.reject(403, 'Unknown user');
+ const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, legacyId);
+ if (existing) return { ok: true, alreadyDone: true };
+ const entry = buildKttCompletionEntry({
+ userId: dbUser.ID, legacyId, title,
+ attemptNumber: 1, nextLegacyId: await getNextLegacyId('TaskRecords', db),
+ });
+ await INSERT.into(TaskRecords).entries(entry);
+ return { ok: true, alreadyDone: false };
+ });
+
+ return super.init();
+ }
+}
+```
+
+> Note: resolve the user by `sapId` (`req.user.id`), matching the platform's test-key convention (`resolveUsers by sapId not uuid`).
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-complete.test.js`
+Expected: PASS (one row, second call `alreadyDone`).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add srv/ktt-service.cds srv/ktt-service.js srv/lib/ktt/completion.js test/unit/ktt-complete.test.js
+git commit -m "feat(ktt): KttService.completeLesson records KTT_LESSON TaskRecord (idempotent)"
+```
+
+---
+
+### Task 3: Progress merge + `syncProgress`
+
+**Files:**
+- Create: `srv/lib/ktt/merge.js`
+- Modify: `srv/ktt-service.js`
+- Test: `test/unit/ktt-merge.test.js`
+
+**Interfaces:**
+- Produces: `mergeProgress(local, remote) => { xp, streak, mastered }` in `srv/lib/ktt/merge.js` — `mastered` = sorted union of arrays of lesson slugs, `xp`/`streak` = numeric max. Action `syncProgress(localJson)` reconstructs remote state from the user's `KTT_LESSON` TaskRecords, merges, records any newly-mastered lessons via the Task 2 path, and returns the merged totals.
+
+- [ ] **Step 1: Write the failing test** — `test/unit/ktt-merge.test.js`:
+
+```js
+const { expect, test } = require('vitest');
+const { mergeProgress } = require('../../srv/lib/ktt/merge.js');
+
+test('mergeProgress unions mastered and takes max xp/streak', () => {
+ const local = { xp: 120, streak: 3, mastered: ['core-1', 'core-2'] };
+ const remote = { xp: 90, streak: 5, mastered: ['core-2', 'sec-1'] };
+ expect(mergeProgress(local, remote)).toEqual({
+ xp: 120, streak: 5, mastered: ['core-1', 'core-2', 'sec-1'],
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-merge.test.js`
+Expected: FAIL (module not found).
+
+- [ ] **Step 3: Create `srv/lib/ktt/merge.js`:**
+
+```js
+export function mergeProgress(local = {}, remote = {}) {
+ const mastered = Array.from(new Set([...(local.mastered || []), ...(remote.mastered || [])])).sort();
+ return {
+ xp: Math.max(Number(local.xp) || 0, Number(remote.xp) || 0),
+ streak: Math.max(Number(local.streak) || 0, Number(remote.streak) || 0),
+ mastered,
+ };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-merge.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Add `syncProgress` handler** to `srv/ktt-service.js` `init()` (uses `mergeProgress` + Task 2 helpers). Import at top: `import { mergeProgress } from './lib/ktt/merge.js';` and `import { KttLessons } from` via `cds.entities`. Handler:
+
+```js
+this.on('syncProgress', async (req) => {
+ const local = JSON.parse(req.data.localJson || '{}');
+ const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id });
+ if (!dbUser) return req.reject(403, 'Unknown user');
+ const done = await SELECT.from(TaskRecords)
+ .columns('taskLegacyId')
+ .where({ user_ID: dbUser.ID, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' } });
+ const doneIds = done.map((r) => r.taskLegacyId);
+ const catalog = await SELECT.from(KttLessons).columns('legacyId', 'slug');
+ const slugByLegacy = new Map(catalog.map((c) => [c.legacyId, c.slug]));
+ const remote = { xp: 0, streak: 0, mastered: doneIds.map((id) => slugByLegacy.get(id)).filter(Boolean) };
+ const merged = mergeProgress(local, remote);
+ // Record any locally-mastered lessons not yet in HANA (reuse completeLesson guard).
+ const legacyBySlug = new Map(catalog.map((c) => [c.slug, c]));
+ for (const slug of merged.mastered) {
+ const cat = legacyBySlug.get(slug);
+ if (!cat || doneIds.includes(cat.legacyId)) continue;
+ const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, cat.legacyId);
+ if (existing) continue;
+ await INSERT.into(TaskRecords).entries(buildKttCompletionEntry({
+ userId: dbUser.ID, legacyId: cat.legacyId, title: cat.slug,
+ nextLegacyId: await getNextLegacyId('TaskRecords', db),
+ }));
+ }
+ return merged;
+});
+```
+
+Add `KttLessons` to the `cds.entities('com.sap.developers.ims')` destructure at the top of `init()`.
+
+- [ ] **Step 6: Extend the test** — add a served-mode assertion in `test/unit/ktt-complete.test.js` (or a new `ktt-sync.test.js`): seed one `KttLessons` row (`legacyId:90001, slug:'core-1'`), POST `/ktt/syncProgress` with `localJson='{"xp":50,"streak":2,"mastered":["core-1"]}'`, assert response `mastered` contains `core-1` and a `KTT_LESSON` TaskRecord for `90001` now exists.
+
+- [ ] **Step 7: Run tests**
+
+Run: `npx vitest run test/unit/ktt-merge.test.js test/unit/ktt-complete.test.js`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add srv/lib/ktt/merge.js srv/ktt-service.js test/unit/ktt-merge.test.js test/unit/ktt-complete.test.js
+git commit -m "feat(ktt): syncProgress merges local and HANA progress on login"
+```
+
+---
+
+### Task 4: Surface KTT lessons in MyCompletions
+
+**Files:**
+- Modify: `srv/lib/user-progress.js` (~`:175`, `:183`, `:213`)
+- Test: `test/unit/ktt-mycompletions.test.js`
+
+**Interfaces:**
+- Consumes: `getMyCompletedTutorials(user)` in `srv/lib/user-progress.js` (called by `getMyCompletions` in `srv/developer-service.js:453`).
+- Produces: entries with `kind:'ktt'` for KTT lesson completions.
+
+- [ ] **Step 1: Write the failing test** — `test/unit/ktt-mycompletions.test.js` (served mode): seed a `KttLessons` row + a `KTT_LESSON` COMPLETED `TaskRecords` row for user `alice`, then call the `getMyCompletions` function and assert an entry with `{ kind: 'ktt', slug: 'core-1', title: 'Meet the Platform' }` is returned.
+
+```js
+const cds = require('@sap/cds');
+const { expect, test } = require('vitest');
+const { GET } = cds.test('serve', '--in-memory', '--project', process.cwd());
+
+test('getMyCompletions includes KTT lessons as kind:ktt', async () => {
+ // Arrange: insert KttLessons + TaskRecords for alice via db (see repo seed helpers).
+ // Act:
+ const res = await GET('/browse/getMyCompletions()', { auth: { username: 'alice' } });
+ // Assert:
+ expect(res.data.value.some((e) => e.kind === 'ktt' && e.slug === 'core-1')).toBe(true);
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-mycompletions.test.js`
+Expected: FAIL (no `kind:'ktt'` entry).
+
+- [ ] **Step 3: Extend `srv/lib/user-progress.js`:**
+ - Add `'KTT_LESSON'` to the `taskType IN (...)` list near `:175`.
+ - Add a `kttIds` bucket near `:183` collecting `taskLegacyId`s where `taskType==='KTT_LESSON'`.
+ - Add a `SELECT.from(KttLessons).where({ legacyId: { in: kttIds } })` metadata fetch (import `KttLessons` from `cds.entities`).
+ - Add a branch near `:213` mapping a KTT row to `{ kind: 'ktt', slug, title: titleSnapshot || lesson.title, completionDate, attemptNumber }`.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-mycompletions.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Regression** — run the existing user-progress suite to confirm TUTORIAL/PUZZLE/PETOBERFEST still behave:
+
+Run: `npx vitest run test/unit/user-progress.test.js` (adjust to the real filename if different)
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/lib/user-progress.js test/unit/ktt-mycompletions.test.js
+git commit -m "feat(ktt): surface KTT lessons in MyCompletions as kind:ktt"
+```
+
+---
+
+### Task 5: `banter` AI action (fail-open)
+
+**Files:**
+- Modify: `srv/ktt-service.js`
+- Test: `test/unit/ktt-banter.test.js`
+
+**Interfaces:**
+- Produces: `function banter(context: String) returns String` — returns a short in-character line; on any AI error/timeout returns a deterministic scripted fallback line (never throws).
+
+- [ ] **Step 1: Write the failing test** — `test/unit/ktt-banter.test.js`:
+
+```js
+const { expect, test, vi } = require('vitest');
+
+test('banter returns a fallback line when AICore fails', async () => {
+ const { computeBanter } = require('../../srv/lib/ktt/banter.js');
+ const brokenAi = { chat: vi.fn(async () => { throw new Error('AICore down'); }) };
+ const line = await computeBanter(brokenAi, { event: 'streak', streak: 5 });
+ expect(typeof line).toBe('string');
+ expect(line.length).toBeGreaterThan(0);
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-banter.test.js`
+Expected: FAIL (`srv/lib/ktt/banter.js` not found).
+
+- [ ] **Step 3: Create `srv/lib/ktt/banter.js`** (pure, testable; the service wires the real AICore in):
+
+```js
+const FALLBACKS = {
+ streak: "Purr-fect. Another one mastered — my nap is well earned.",
+ wrong: "A miss! Even I cough up the occasional hairball. Try again.",
+ correct: "Correct. You may pet me. Once.",
+ default: "Acronyms: SAP's favorite indoor sport.",
+};
+const MAX_LEN = 160;
+
+/** ai: object with async chat(prompt) → string, or null. Never throws. */
+export async function computeBanter(ai, ctx = {}) {
+ const fallback = FALLBACKS[ctx.event] || FALLBACKS.default;
+ if (!ai || typeof ai.chat !== 'function') return fallback;
+ try {
+ const prompt = `You are Professor Kasimir, a witty SAP cat. One short, tongue-in-cheek line (<160 chars) for event "${ctx.event}". No emojis.`;
+ const out = await Promise.race([
+ ai.chat(prompt),
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 2500)),
+ ]);
+ const line = String(out || '').trim().slice(0, MAX_LEN);
+ return line || fallback;
+ } catch {
+ return fallback;
+ }
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-banter.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Wire the action** in `srv/ktt-service.js` `init()`:
+
+```js
+import { computeBanter } from './lib/ktt/banter.js';
+// ... inside init():
+let ai = null;
+try { ai = await cds.connect.to('AICore'); } catch { ai = null; }
+const aiAdapter = ai ? { chat: async (p) => (await ai.send('POST', '/chat', { prompt: p }))?.text } : null;
+this.on('banter', async (req) => computeBanter(aiAdapter, { event: req.data.context }));
+```
+
+> The exact AICore call shape follows the `@cap-js/ai` usage in the repo; `computeBanter` is agnostic to it and fail-open, so a wrong call shape degrades to the fallback rather than breaking the lesson. Local dev uses `AICore-mocked`; do not require a live model for tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/lib/ktt/banter.js srv/ktt-service.js test/unit/ktt-banter.test.js
+git commit -m "feat(ktt): fail-open Kasimir banter action backed by @cap-js/ai"
+```
+
+---
+
+### Task 6: Feature flag `KTT_ENABLED` + gate the service
+
+**Files:**
+- Modify: `srv/lib/feature-flags/registry.js`, `srv/ktt-service.js`
+- Test: `test/unit/feature-flags-registry.test.js` (existing drift test must pass)
+
+**Interfaces:**
+- Produces: registry entry `KTT_ENABLED` (`imsConfigKey:'flag.ktt.enabled'`), read via `isFlagEnabled('KTT_ENABLED')`.
+
+- [ ] **Step 1: Add the registry entry.** Append to the `FEATURE_FLAGS` array in `srv/lib/feature-flags/registry.js`:
+
+```js
+{
+ key: 'KTT_ENABLED', label: 'KTT — Kasimir Teaches TLAs', category: 'Content',
+ kind: 'db', imsConfigKey: 'flag.ktt.enabled',
+ valueType: 'boolean', default: false, status: 'beta',
+ description: 'Enables the /explore/ktt/ acronym trainer and its /ktt CAP endpoints. Off → completeLesson/syncProgress reject 503.',
+ howToChange: featureFlagUpsert('KTT_ENABLED', 'flag.ktt.enabled'),
+},
+```
+
+- [ ] **Step 2: Run the drift test to verify the registry is well-formed**
+
+Run: `npx vitest run test/unit/feature-flags-registry.test.js`
+Expected: PASS (the entry is valid; no drift).
+
+- [ ] **Step 3: Gate the write actions.** At the top of `completeLesson` and `syncProgress` handlers in `srv/ktt-service.js`, add:
+
+```js
+import { isFlagEnabled } from './lib/feature-flags/db-flags.js';
+// first line of each write handler:
+if (!isFlagEnabled('KTT_ENABLED')) return req.reject(503, 'KTT is not enabled');
+```
+
+- [ ] **Step 4: Add a served test** asserting `completeLesson` rejects `503` when the flag default (OFF) is in effect, and passes when the flag is forced on (set the ImsConfig row / mock `isFlagEnabled` per the repo's existing flag-test pattern). Guard any DB-flag boot-seed behind `!process.env.VITEST`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/feature-flags/registry.js srv/ktt-service.js test/unit/*.test.js
+git commit -m "feat(ktt): register KTT_ENABLED db flag and gate /ktt write actions"
+```
+
+---
+
+## Phase B — Content pipeline
+
+### Task 7: Lesson schema, generator, and reviewed data file
+
+**Files:**
+- Create: `scripts/lib/ktt-lessons-schema.ts`, `scripts/generate-ktt-lessons.ts`, `hugo/data/ktt_lessons.json`
+- Modify: `package.json` (script only; `build:all` wiring in Task 16)
+- Test: `test/unit/ktt-lessons-data.test.ts`
+
+**Interfaces:**
+- Produces: TS types `KttLesson`, `KttUnit`, `KttBeat`, and `validateLessons(data): string[]` (returns error messages; empty = valid) in `scripts/lib/ktt-lessons-schema.ts`. The generated `hugo/data/ktt_lessons.json` matches the spec §5 shape.
+
+- [ ] **Step 1: Write the failing validation test** — `test/unit/ktt-lessons-data.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { validateLessons } from '../../scripts/lib/ktt-lessons-schema';
+
+describe('ktt_lessons.json', () => {
+ const data = JSON.parse(readFileSync('hugo/data/ktt_lessons.json', 'utf8'));
+ it('has 3-4 units and passes structural validation', () => {
+ expect(data.units.length).toBeGreaterThanOrEqual(3);
+ expect(data.units.length).toBeLessThanOrEqual(4);
+ expect(validateLessons(data)).toEqual([]);
+ });
+ it('every drill has an answer and >=2 distractors; legacyIds unique', () => {
+ const errs = validateLessons(data);
+ expect(errs).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npx vitest run test/unit/ktt-lessons-data.test.ts`
+Expected: FAIL (schema module + data file missing).
+
+- [ ] **Step 3: Create `scripts/lib/ktt-lessons-schema.ts`:**
+
+```ts
+export interface KttAcronym { tla: string; expansion: string; blurb: string; category: string; }
+export interface KttDrill {
+ type: 'drill'; kind: 'mc' | 'match' | 'type'; tla: string; prompt: string;
+ answer: string; distractors: string[]; kasimirRight: string; kasimirWrong: string;
+}
+export interface KttStory { type: 'story'; kasimir: string; mood: 'idle'|'teaching'|'thinking'|'correct'|'wrong'|'celebrate'; }
+export type KttBeat = KttStory | KttDrill;
+export interface KttLesson { id: string; legacyId: number; title: string; acronyms: KttAcronym[]; beats: KttBeat[]; }
+export interface KttUnit { id: string; title: string; icon: string; order: number; lessons: KttLesson[]; }
+export interface KttData { units: KttUnit[]; }
+
+export function validateLessons(data: KttData): string[] {
+ const errs: string[] = [];
+ const legacyIds = new Set();
+ for (const u of data.units || []) {
+ for (const l of u.lessons || []) {
+ if (typeof l.legacyId !== 'number') errs.push(`${l.id}: missing numeric legacyId`);
+ if (legacyIds.has(l.legacyId)) errs.push(`${l.id}: duplicate legacyId ${l.legacyId}`);
+ legacyIds.add(l.legacyId);
+ for (const a of l.acronyms || []) if (!a.expansion) errs.push(`${l.id}/${a.tla}: missing expansion`);
+ for (const b of l.beats || []) {
+ if (b.type === 'drill') {
+ if (!b.answer) errs.push(`${l.id}: drill for ${b.tla} missing answer`);
+ if (!b.distractors || b.distractors.length < 2) errs.push(`${l.id}: drill for ${b.tla} needs >=2 distractors`);
+ }
+ }
+ }
+ }
+ return errs;
+}
+```
+
+- [ ] **Step 4: Create `scripts/generate-ktt-lessons.ts`.** It (a) reads a curated seed list of `{tla, expansion, category, unitId}` embedded in the script (facts grounded against `sap-devs`/`cds-mcp`/official docs during authoring — NOT invented), (b) optionally calls the AI drafting step for `kasimir*` lines + `distractors` when `--draft` is passed, (c) assembles `KttData`, (d) runs `validateLessons` and **exits non-zero on any error**, (e) writes `hugo/data/ktt_lessons.json` with stable ordering. Skeleton:
+
+```ts
+import { writeFileSync } from 'node:fs';
+import { validateLessons, KttData } from './lib/ktt-lessons-schema';
+
+const SEED = [/* curated + grounded TLA facts, assigned to units + legacyIds */];
+
+function assemble(): KttData { /* group SEED by unitId, build beats (story+drills) */ return { units: [] }; }
+
+const data = assemble();
+const errs = validateLessons(data);
+if (errs.length) { console.error('KTT lesson validation failed:\n' + errs.join('\n')); process.exit(1); }
+writeFileSync('hugo/data/ktt_lessons.json', JSON.stringify(data, null, 2) + '\n');
+console.log(`Wrote ${data.units.length} units.`);
+```
+
+- [ ] **Step 5: Author the reviewed data file.** Run the generator (or hand-author) to produce `hugo/data/ktt_lessons.json` with 3–4 units and ~50–60 acronyms. **The TLA→expansion facts are human-reviewed for correctness before commit** (Global Constraints). This is the content-authoring step — grounded, not model-guessed.
+
+- [ ] **Step 6: Add the npm script** to `package.json` `scripts`:
+
+```json
+"generate-ktt-lessons": "npx tsx scripts/generate-ktt-lessons.ts"
+```
+
+- [ ] **Step 7: Run the validation test to verify it passes**
+
+Run: `npx vitest run test/unit/ktt-lessons-data.test.ts`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add scripts/lib/ktt-lessons-schema.ts scripts/generate-ktt-lessons.ts hugo/data/ktt_lessons.json package.json test/unit/ktt-lessons-data.test.ts
+git commit -m "feat(ktt): lesson schema, generator, and reviewed content data file"
+```
+
+---
+
+## Phase C — Frontend island
+
+### Task 8: Scaffold `ktt` island + register in Vite
+
+**Files:**
+- Create: `hugo-apps/src/ktt/main.ts`, `hugo-apps/src/ktt/App.vue`
+- Modify: `hugo-apps/vite.config.ts` (add `ktt` to `rollupOptions.input`, near `:349`)
+- Test: `hugo-apps/src/ktt/__tests__/App.test.ts`
+
+**Interfaces:**
+- Produces: island entry mounting `App.vue` on `#ktt-mount`, reading `data-api` (default `/ktt`) and `data-lessons` (path to `ktt_lessons.json`, default `/data/ktt_lessons.json`... — see note). Screen switch driven by a `screen` ref: `'landing'|'map'|'lesson'|'results'`.
+
+> Lessons data note: Hugo bakes `hugo/data/ktt_lessons.json` into the page; pass it to the island via a `data-lessons` attribute containing the JSON (or a URL). Simplest: emit the JSON inline in the layout (Task 14) and read `mount.dataset.lessons`. The island therefore does NOT fetch lessons from CAP in v1.
+
+- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/App.test.ts`:
+
+```ts
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import App from '../App.vue';
+
+const LESSONS = { units: [{ id: 'core', title: 'Core Platform', icon: 'home', order: 1,
+ lessons: [{ id: 'core-1', legacyId: 90001, title: 'Meet the Platform', acronyms: [], beats: [] }] }] };
+
+describe('KTT App', () => {
+ it('renders the landing screen with a start CTA', () => {
+ const wrapper = mount(App, { props: { apiUrl: '/ktt', lessons: LESSONS } });
+ expect(wrapper.text()).toContain('Kasimir');
+ expect(wrapper.find('[data-testid="ktt-start"]').exists()).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/App.test.ts`
+Expected: FAIL (`App.vue` missing).
+
+- [ ] **Step 3: Create `hugo-apps/src/ktt/main.ts`:**
+
+```ts
+import { createApp } from 'vue';
+import App from './App.vue';
+
+const mount = document.getElementById('ktt-mount') as HTMLElement | null;
+if (mount) {
+ let lessons = {};
+ try { lessons = JSON.parse(mount.dataset.lessons || '{}'); } catch { lessons = {}; }
+ createApp(App, { apiUrl: mount.dataset.api || '/ktt', lessons }).mount(mount);
+}
+```
+
+- [ ] **Step 4: Create a minimal `hugo-apps/src/ktt/App.vue`** (landing only for now; screens added in later tasks):
+
+```vue
+
+
+
+
+
+
Professor Kasimir Teaches TLAs
+
SAP has more three-letter acronyms than a cat has naps. Let's fix that.
+
+
+
+
+
+```
+
+> This imports `KasimirStage` (Task 10). To keep Task 8 independently green, create a one-line stub `components/KasimirStage.vue` now (``) and replace it in Task 10.
+
+- [ ] **Step 5: Register the island** in `hugo-apps/vite.config.ts` `rollupOptions.input` (near `:349`, beside `puzzle`):
+
+```ts
+ktt: resolve(__dirname, 'src/ktt/main.ts'),
+```
+
+- [ ] **Step 6: Run to verify it passes**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/App.test.ts`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add hugo-apps/src/ktt hugo-apps/vite.config.ts
+git commit -m "feat(ktt): scaffold ktt Vue island and register Vite entry"
+```
+
+---
+
+### Task 9: Lesson engine (pure)
+
+**Files:**
+- Create: `hugo-apps/src/ktt/lib/engine.ts`
+- Test: `hugo-apps/src/ktt/__tests__/engine.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `createSession(lesson: KttLesson): Session` where `Session = { beats: KttBeat[]; index: number; xp: number; correct: number; wrong: number; queue: number[] }`.
+ - `currentBeat(s): KttBeat | null`
+ - `answerDrill(s, answer: string): { correct: boolean; requeued: boolean }` — wrong answers push the beat index to the end of `queue` (re-queue once); correct awards `XP_PER_CORRECT`.
+ - `advance(s): void` — moves to the next queued beat.
+ - `isComplete(s): boolean`
+ - constant `XP_PER_CORRECT = 10`.
+
+- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/engine.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { createSession, currentBeat, answerDrill, advance, isComplete, XP_PER_CORRECT } from '../lib/engine';
+
+const lesson = { id: 'core-1', legacyId: 90001, title: 't', acronyms: [], beats: [
+ { type: 'story', kasimir: 'hi', mood: 'teaching' },
+ { type: 'drill', kind: 'mc', tla: 'BTP', prompt: '?', answer: 'Business Technology Platform',
+ distractors: ['a','b'], kasimirRight: 'y', kasimirWrong: 'n' },
+]} as any;
+
+describe('engine', () => {
+ it('awards XP on correct and re-queues on wrong', () => {
+ const s = createSession(lesson);
+ advance(s); // move past story to drill
+ expect(currentBeat(s).type).toBe('drill');
+ const wrong = answerDrill(s, 'a');
+ expect(wrong.correct).toBe(false);
+ expect(wrong.requeued).toBe(true);
+ advance(s);
+ const right = answerDrill(s, 'Business Technology Platform');
+ expect(right.correct).toBe(true);
+ expect(s.xp).toBe(XP_PER_CORRECT);
+ advance(s);
+ expect(isComplete(s)).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/engine.test.ts`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `hugo-apps/src/ktt/lib/engine.ts`** to satisfy the interface above (session tracks a `queue` of remaining beat indices; `answerDrill` compares case-insensitively; wrong beats re-queue at most once via a `requeuedSet`).
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/engine.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hugo-apps/src/ktt/lib/engine.ts hugo-apps/src/ktt/__tests__/engine.test.ts
+git commit -m "feat(ktt): pure lesson engine — scoring, XP, wrong-answer re-queue"
+```
+
+---
+
+### Task 10: `KasimirStage` — rigged SVG + mood state machine
+
+**Files:**
+- Replace stub: `hugo-apps/src/ktt/components/KasimirStage.vue`
+- Test: `hugo-apps/src/ktt/__tests__/KasimirStage.test.ts`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: component prop `mood: 'idle'|'teaching'|'thinking'|'correct'|'wrong'|'celebrate'`. Root element carries `class="kasimir kasimir--"` and `aria-hidden="true"`. Honors `prefers-reduced-motion` by adding `kasimir--still`.
+
+- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/KasimirStage.test.ts`:
+
+```ts
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import KasimirStage from '../components/KasimirStage.vue';
+
+describe('KasimirStage', () => {
+ it('reflects mood in the root class and is aria-hidden', () => {
+ const w = mount(KasimirStage, { props: { mood: 'celebrate' } });
+ const root = w.find('.kasimir');
+ expect(root.classes()).toContain('kasimir--celebrate');
+ expect(root.attributes('aria-hidden')).toBe('true');
+ expect(w.find('svg').exists()).toBe(true);
+ });
+ it('switches class when mood changes', async () => {
+ const w = mount(KasimirStage, { props: { mood: 'idle' } });
+ await w.setProps({ mood: 'wrong' });
+ expect(w.find('.kasimir').classes()).toContain('kasimir--wrong');
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/KasimirStage.test.ts`
+Expected: FAIL (stub has no `svg`/mood class).
+
+- [ ] **Step 3: Implement `KasimirStage.vue`.** Build an **original stylized SVG Kasimir** (grey tuxedo body, white blaze/muzzle/chest/paws, big blue eyes + pupils, pink nose, dark collar + blue SAP-arrow tag) with each animated part as its own `` (`ears`, `eyelids`, `pupils`, `mouth`, `whiskers`, `tail`, `pawLeft`, `pawRight`, `head`, `body`). Root binds the mood class; CSS keyframes per mood drive the rig; `prefers-reduced-motion` freezes to a calm pose. Structure:
+
+```vue
+
+
+
+
+
+
+
+```
+
+> The functional contract (prop → root class → `svg` present → reduced-motion) is fully tested here. **Visual refinement of the SVG paths against the reference images is an in-task iteration** using `run` to view the island in a browser; the art is original stylized vector work (SAP-owned likeness, recreated — not a copy of the source illustrations). Ship a recognizable Kasimir; polish is expected before the flag flips ON.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `npm test -- hugo-apps/src/ktt/__tests__/KasimirStage.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hugo-apps/src/ktt/components/KasimirStage.vue hugo-apps/src/ktt/__tests__/KasimirStage.test.ts
+git commit -m "feat(ktt): rigged SVG Kasimir with mood state machine + reduced-motion"
+```
+
+---
+
+### Task 11: `SkillTreeMap` component
+
+**Files:**
+- Create: `hugo-apps/src/ktt/components/SkillTreeMap.vue`
+- Test: `hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts`
+
+**Interfaces:**
+- Consumes: `lessons: KttData`, `mastered: string[]` (lesson ids), emits `select(lessonId: string)`.
+- Produces: renders one node per lesson; a node is `is-locked` unless it's the first of a unit or its previous sibling id is in `mastered`; mastered nodes get `is-done`.
+
+- [ ] **Step 1: Write the failing test** — assert: given two lessons in a unit and `mastered=[]`, the first node is selectable and the second has class `is-locked`; clicking the first emits `select` with its id; with `mastered=['core-1']`, `core-2` is unlocked.
+
+- [ ] **Step 2: Run to verify it fails.** Run: `npm test -- hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts` → FAIL.
+
+- [ ] **Step 3: Implement `SkillTreeMap.vue`** to satisfy the lock/unlock/done + emit contract above. Include a `` beside the map.
+
+- [ ] **Step 4: Run to verify it passes.** Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hugo-apps/src/ktt/components/SkillTreeMap.vue hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts
+git commit -m "feat(ktt): skill-tree map with lesson lock/unlock/done states"
+```
+
+---
+
+### Task 12: `Lesson` component + wire App screens + local progress + server client
+
+**Files:**
+- Create: `hugo-apps/src/ktt/components/Lesson.vue`, `hugo-apps/src/ktt/components/Results.vue`, `hugo-apps/src/ktt/lib/progress.ts`, `hugo-apps/src/ktt/lib/server.ts`
+- Modify: `hugo-apps/src/ktt/App.vue`
+- Test: `hugo-apps/src/ktt/__tests__/progress.test.ts`, `hugo-apps/src/ktt/__tests__/Lesson.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `progress.ts`: `loadLocal(): { xp:number; streak:number; mastered:string[] }`, `saveLocal(p)`, `mergeProgress(local, remote)` (same union/max semantics as the backend `srv/lib/ktt/merge.js`).
+ - `server.ts`: `isAuthenticated(): Promise` (probes `/auth/user`, checks JSON `body.authenticated`, NOT `r.ok`), `completeLesson(apiUrl, {lessonSlug, legacyId, title})`, `syncProgress(apiUrl, local)`, `fetchBanter(apiUrl, context)`. All server calls are best-effort: failures are caught and never throw into the UI.
+ - `Lesson.vue`: props `lesson`, drives the Task 9 engine, shows current beat, binds `KasimirStage` mood (`teaching` on story, `correct`/`wrong` after answers, `celebrate` on complete), emits `complete(lessonId)`.
+
+- [ ] **Step 1: Write failing `progress.test.ts`** — assert `saveLocal`/`loadLocal` round-trip via `localStorage` (happy-dom), and `mergeProgress` matches the backend semantics (union mastered, max xp/streak). Reuse the exact expectation from `test/unit/ktt-merge.test.js`.
+
+- [ ] **Step 2: Run to verify it fails.** Run: `npm test -- hugo-apps/src/ktt/__tests__/progress.test.ts` → FAIL.
+
+- [ ] **Step 3: Implement `progress.ts` and `server.ts`.** `isAuthenticated` must parse JSON and check `body.authenticated` (per the platform's auth-probe gotcha). `server.ts` uses native `fetch`.
+
+- [ ] **Step 4: Write failing `Lesson.test.ts`** — mount `Lesson` with a two-beat lesson (story + one drill), simulate a correct answer, assert XP shows and `complete` is emitted after the last beat; assert Kasimir mood becomes `celebrate` on completion. Stub `server.ts` via `vi.mock`.
+
+- [ ] **Step 5: Run to verify it fails.** → FAIL.
+
+- [ ] **Step 6: Implement `Lesson.vue` + `Results.vue`, and wire `App.vue`:** landing → map (`SkillTreeMap`) → lesson (`Lesson`) → results (`Results`). On `Lesson`'s `complete`: update local progress (`saveLocal`), then if `isAuthenticated()` call `completeLesson` + `syncProgress` (best-effort), then show `Results` / return to map with the node marked done. Fetch a `banter` line opportunistically (fail-open — never block).
+
+- [ ] **Step 7: Run all ktt island tests.** Run: `npm test -- hugo-apps/src/ktt` → PASS.
+
+- [ ] **Step 8: srv/lib cp-list audit.** Confirm no new `srv/lib/ktt/*` file is imported (transitively) by `srv/lib/content-store.js`:
+
+Run: `npx ast-grep -p "require('./ktt/$_')" srv/lib/content-store.js` (and grep for `ktt/` import specifiers reachable from it). Expected: no matches → no `.deploy/mta.yaml` `srv-qa` cp-list change needed. If any match appears, add the file(s) to the `srv-qa` `cp` list.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add hugo-apps/src/ktt
+git commit -m "feat(ktt): lesson runner, results, local progress + best-effort server sync"
+```
+
+---
+
+## Phase D — Hugo page, nav, build wiring, e2e
+
+### Task 13: Hugo page + layout (SSR shell + island mount)
+
+**Files:**
+- Create: `hugo/content/explore/ktt/_index.md`, `hugo/layouts/explore/ktt.html`
+- Test: manual Hugo render (`npm run dev`) — verified in Task 16 build.
+
+**Interfaces:**
+- Consumes: `hugo/data/ktt_lessons.json` (Task 7); `island-src.html` partial (`ktt`).
+- Produces: `/explore/ktt/` route rendering an SSR shell + `#ktt-mount` with `data-api="/ktt"` and `data-lessons` = the baked lessons JSON.
+
+- [ ] **Step 1: Create `hugo/content/explore/ktt/_index.md`:**
+
+```yaml
+---
+title: "KTT — Kasimir Teaches TLAs"
+description: "Professor Kasimir teaches you SAP's endless three-letter acronyms — a mostly-fun, tongue-in-cheek trainer."
+type: explore
+layout: ktt
+slug: ktt
+---
+```
+
+- [ ] **Step 2: Create `hugo/layouts/explore/ktt.html`:**
+
+```html
+{{ define "main" }}
+
+
+
+
{{ .Title }}
+
{{ .Description }}
+
+ {{ range site.Data.ktt_lessons.units }}
{{ .title }}
{{ end }}
+
+
+
+
+
+{{ end }}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add hugo/content/explore/ktt/_index.md hugo/layouts/explore/ktt.html
+git commit -m "feat(ktt): Hugo page + layout with SSR shell and island mount"
+```
+
+---
+
+### Task 14: Explore nav entry
+
+**Files:**
+- Modify: `hugo/data/navigation.yaml` (under the `id: explore` group, `:82`)
+
+- [ ] **Step 1: Add the menu item** under the Explore group (mirror the existing explore items' fields):
+
+```yaml
+- id: explore-ktt
+ label: "Kasimir Teaches TLAs"
+ href: /explore/ktt/
+ icon: learning-assistant
+ paletteLabel: "KTT — Kasimir Teaches TLAs"
+ keywords: ["acronym", "tla", "kasimir", "quiz", "learn"]
+```
+
+- [ ] **Step 2: Verify Hugo builds the nav** — `npm run dev`, load `/`, confirm "Kasimir Teaches TLAs" appears under Explore and links to `/explore/ktt/`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add hugo/data/navigation.yaml
+git commit -m "feat(ktt): add KTT to the Explore navigation menu"
+```
+
+---
+
+### Task 15: Wire content generation into `build:all`
+
+**Files:**
+- Modify: `package.json` (`build:all`)
+
+- [ ] **Step 1: Insert `generate-ktt-lessons`** into the leading `fetch-*` chain of `build:all` (before `build:apps`), analogous to `fetch-tutorials`:
+
+```
+... && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run generate-ktt-lessons && npm run build:icon-subset && ...
+```
+
+- [ ] **Step 2: Run a scoped build to verify wiring** — regenerate lessons + build apps + manifest + hugo:
+
+Run: `npm run generate-ktt-lessons && npm run build:apps && npm run build:island-manifest`
+Expected: `hugo/data/ktt_lessons.json` regenerated; `hugo/static/js/ktt-.js` produced; `ktt` key present in `hugo/data/island_manifest.json`.
+
+- [ ] **Step 3: Verify the page renders end-to-end** — `npm run build:hugo` then serve `hugo/public/explore/ktt/index.html`; confirm the `#ktt-mount` `data-lessons` is populated and the script src is the hashed `/js/ktt-.js`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add package.json
+git commit -m "build(ktt): generate ktt lessons as part of build:all"
+```
+
+---
+
+### Task 16: Post-deploy e2e smoke (self-skipping)
+
+**Files:**
+- Create: `test/e2e/ktt.spec.ts`
+
+**Interfaces:**
+- Consumes: `SMOKE_BASE_URL` env (self-skips when absent, per the platform e2e pattern).
+
+- [ ] **Step 1: Write `test/e2e/ktt.spec.ts`** — skip unless `SMOKE_BASE_URL` set; navigate to `${SMOKE_BASE_URL}/explore/ktt/`; assert the SSR shell `
` and unit list render; click `[data-testid="ktt-start"]`; assert the map screen appears; start the first lesson; answer one drill; assert Kasimir/feedback updates. Follow the structure in `test/e2e/README.md`.
+
+- [ ] **Step 2: Local self-skip check**
+
+Run: `npm run test:e2e` (with `SMOKE_BASE_URL` unset)
+Expected: test self-skips (no failure).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add test/e2e/ktt.spec.ts
+git commit -m "test(ktt): post-deploy e2e smoke for /explore/ktt/ (self-skipping)"
+```
+
+---
+
+## Final verification (before opening the PR)
+
+- [ ] Run the full unit suite: `npm test` → PASS (KTT + no regressions).
+- [ ] `npx cds deploy --to sqlite::memory:` → clean.
+- [ ] `npm run build:all` (with `CAP_BASE_URL` pointed at the deployed backend per repo convention) → completes; `ktt` island hashed + in manifest; `/explore/ktt/` renders.
+- [ ] Confirm `KTT_ENABLED` is **OFF** by default (route/endpoints fail closed) — flip ON only after DEV verification.
+- [ ] `git fetch origin` and diff the branch against `origin/DEV` (KTT work targets DEV, not main).
+- [ ] Open a PR to **DEV** (`gh pr create`), not a direct merge.
+
+---
+
+## Deferred Work (explicitly out of v1 scope)
+
+1. **Unit-level rollup.** Seed each unit as a mission-style parent (`Missions`/`Groups` row) with `CompletionPathItems` children `KTT_LESSON:`, and add a `rollUpParentsForCompletion({ task:{ taskType:'KTT_LESSON', taskLegacyId } })` call in `completeLesson`. `completion-rollup.js:159` then produces "unit mastered" rows with no rollup-code change. Requires confirming the Missions/`CompletionPathItems` seed shape first (not specified here to avoid guessing).
+2. **Endless mode** over the full acronym firehose — the engine's beat source is already pluggable; add a CAP-backed acronym feed.
+3. **Per-acronym `STEP` completion rows** for finer mastery tracking.
+4. **NGDS autosend** for KTT completions.
+5. **Homepage/featured surfacing** and gameboard scoring tie-ins.
diff --git a/docs/superpowers/plans/2026-09-11-signed-provenance-envelope.md b/docs/superpowers/plans/2026-09-11-signed-provenance-envelope.md
new file mode 100644
index 000000000..5a2fc559c
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-signed-provenance-envelope.md
@@ -0,0 +1,959 @@
+# Signed Tutorial Provenance & Freshness Attestation — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Serve a per-tutorial cryptographically signed envelope (compact JWS / EdDSA) carrying separate provenance and freshness claims, plus advisory headers on the HTML serve path, so any third-party AI agent can verify and down-weight stale SAP tutorial content.
+
+**Architecture:** New `srv/lib/provenance-*.js` modules build claims from the existing `FreshnessReport` + `ContentCurrent` rows, sign them with `jose` using an Ed25519 key loaded from the BTP Credential Store, and cache the JWS by `(contentHash, runAt)`. A sibling `/content/tutorials/:slug/provenance` route serves the JWS; a `/.well-known/tutorial-provenance/jwks.json` route publishes the public key. Advisory (unsigned) headers are added inside `serveStoredSlug`. Everything is DB-flag-gated and fail-open.
+
+**Tech Stack:** CAP Node.js (`@sap/cds` 10), Express (bootstrap block in `srv/server.js`), `jose` 6.2.3 (EdDSA / JWS / JWKS), `node:crypto`, HANA/SQLite via CDS, vitest 4 + `@cap-js/cds-test`.
+
+**Spec:** `docs/superpowers/specs/2026-09-11-2245-signed-provenance-envelope-design.md`
+
+## Global Constraints
+
+- **Node floor `>=22.12`; ESM only** (`"type":"module"`) — use `import`.
+- **Feature flags are DB config, never env** — register in `srv/lib/feature-flags/registry.js` (`kind:'db'`), read via synchronous `isFlagEnabled(key)` from `srv/lib/feature-flags/db-flags.js`. A drift test fails the build if a flag is used without a registry entry.
+- **Fail-open everywhere** — any signing/key/lookup error serves content normally; the provenance endpoint returns 503; nothing throws into the content path.
+- **No new runtime dependency** — `jose` is already in `dependencies`; do not add crypto libs.
+- **Secrets never in source/env-committed** — the Ed25519 private key comes from the BTP Credential Store, surfaced at runtime as env `PROVENANCE_SIGNING_KEY` (PKCS8 PEM).
+- **Slugs are lowercase-canonical** — `.toLowerCase()` before any slug comparison/lookup.
+- **Thresholds are module constants for v1:** `FRESH_MAX_AGE_DAYS = 30`, `STALE_AGE_DAYS = 90`, `ATTESTATION_TTL_SECONDS = 86400`.
+- **`srv/lib/` change → re-audit `srv-qa` `cp` list** in `.deploy/mta.yaml` for any new transitive `./` import reachable from `content-store.js`.
+
+---
+
+### Task 1: Register the `PROVENANCE_ENVELOPE_ENABLED` feature flag
+
+**Files:**
+- Modify: `srv/lib/feature-flags/registry.js` (add entry near `FRESHNESS_SCAN_ENABLED`)
+- Test: `test/unit/feature-flags-registry.test.js` (existing drift test — must stay green)
+
+**Interfaces:**
+- Produces: registry key `'PROVENANCE_ENVELOPE_ENABLED'` (ImsConfig key `flag.provenance.envelope`), read via `isFlagEnabled('PROVENANCE_ENVELOPE_ENABLED')`.
+
+- [ ] **Step 1: Run the existing registry drift test to confirm baseline green**
+
+Run: `npx vitest run --project unit test/unit/feature-flags-registry.test.js`
+Expected: PASS.
+
+- [ ] **Step 2: Add the flag entry**
+
+In `srv/lib/feature-flags/registry.js`, next to the `FRESHNESS_SCAN_ENABLED` entry, add:
+
+```js
+{
+ key: 'PROVENANCE_ENVELOPE_ENABLED', label: 'Signed provenance & freshness envelope', category: 'Content',
+ kind: 'db', imsConfigKey: 'flag.provenance.envelope',
+ valueType: 'boolean', default: false, status: 'dev-only',
+ description: 'When true, serves the signed provenance JWS at /content/tutorials/:slug/provenance, publishes the JWKS at /.well-known/tutorial-provenance/jwks.json, and emits advisory X-Freshness-Confidence / X-Content-Provenance headers. DB-driven config (ImsConfig key flag.provenance.envelope); no env var. Default OFF.',
+ howToChange: featureFlagUpsert('PROVENANCE_ENVELOPE_ENABLED', 'flag.provenance.envelope'),
+},
+```
+
+- [ ] **Step 3: Run the drift test again**
+
+Run: `npx vitest run --project unit test/unit/feature-flags-registry.test.js`
+Expected: PASS (new flag recognized; no "unregistered flag" failure).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add srv/lib/feature-flags/registry.js
+git commit -m "feat(2245): register PROVENANCE_ENVELOPE_ENABLED db feature flag"
+```
+
+---
+
+### Task 2: Ed25519 key module — load signer + expose JWKS
+
+**Files:**
+- Create: `srv/lib/provenance-keys.js`
+- Test: `test/unit/provenance-keys.test.js`
+
+**Interfaces:**
+- Consumes: env `PROVENANCE_SIGNING_KEY` (PKCS8 PEM, Ed25519).
+- Produces:
+ - `async getSigningKey()` → `{ key: KeyLike, kid: string } | null` (null when key absent/invalid — fail-open signal)
+ - `async getJwks()` → `{ keys: JWK[] }` (empty `keys: []` when no key)
+ - test helper `__setKeyForTest(pkcs8Pem | null)` and `__resetKeysForTest()`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/provenance-keys.test.js
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import { generateKeyPair, exportPKCS8, jwtVerify, importJWK } from 'jose';
+import { getSigningKey, getJwks, __setKeyForTest, __resetKeysForTest } from '../../srv/lib/provenance-keys.js';
+
+let pem;
+beforeAll(async () => {
+ const { privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
+ pem = await exportPKCS8(privateKey);
+});
+afterEach(() => __resetKeysForTest());
+
+describe('provenance-keys', () => {
+ it('returns null signer when no key configured', async () => {
+ __setKeyForTest(null);
+ expect(await getSigningKey()).toBeNull();
+ expect((await getJwks()).keys).toEqual([]);
+ });
+
+ it('loads an Ed25519 signer and publishes a matching public JWK', async () => {
+ __setKeyForTest(pem);
+ const signer = await getSigningKey();
+ expect(signer).not.toBeNull();
+ expect(signer.kid).toMatch(/.+/);
+ const jwks = await getJwks();
+ expect(jwks.keys).toHaveLength(1);
+ expect(jwks.keys[0]).toMatchObject({ kty: 'OKP', crv: 'Ed25519', use: 'sig', alg: 'EdDSA', kid: signer.kid });
+ expect(jwks.keys[0].d).toBeUndefined(); // never leak the private scalar
+ // round-trip: sign with signer, verify with the published public JWK
+ const { SignJWT } = await import('jose');
+ const jws = await new SignJWT({ t: 1 }).setProtectedHeader({ alg: 'EdDSA', kid: signer.kid }).sign(signer.key);
+ const pub = await importJWK(jwks.keys[0], 'EdDSA');
+ const { payload } = await jwtVerify(jws, pub);
+ expect(payload.t).toBe(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/unit/provenance-keys.test.js`
+Expected: FAIL (module not found).
+
+- [ ] **Step 3: Implement `srv/lib/provenance-keys.js`**
+
+```js
+import { importPKCS8, exportJWK, calculateJwkThumbprint } from 'jose';
+
+let _testPem; // when set (incl. null), overrides env — for tests only
+let _cache; // memoized { key, kid, jwk } | null
+
+function readPem() {
+ if (_testPem !== undefined) return _testPem;
+ return process.env.PROVENANCE_SIGNING_KEY || null;
+}
+
+async function load() {
+ if (_cache !== undefined) return _cache;
+ const pem = readPem();
+ if (!pem) { _cache = null; return _cache; }
+ try {
+ const key = await importPKCS8(pem, 'EdDSA', { extractable: true });
+ const priv = await exportJWK(key);
+ const jwk = { kty: priv.kty, crv: priv.crv, x: priv.x }; // public-only
+ const kid = await calculateJwkThumbprint(jwk);
+ _cache = { key, kid, jwk: { ...jwk, use: 'sig', alg: 'EdDSA', kid } };
+ } catch (e) {
+ console.warn('[provenance-keys] failed to load signing key, disabling:', e.message);
+ _cache = null;
+ }
+ return _cache;
+}
+
+export async function getSigningKey() {
+ const c = await load();
+ return c ? { key: c.key, kid: c.kid } : null;
+}
+
+export async function getJwks() {
+ const c = await load();
+ return { keys: c ? [c.jwk] : [] };
+}
+
+export function __setKeyForTest(pem) { _testPem = pem; _cache = undefined; }
+export function __resetKeysForTest() { _testPem = undefined; _cache = undefined; }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/unit/provenance-keys.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/provenance-keys.js test/unit/provenance-keys.test.js
+git commit -m "feat(2245): Ed25519 provenance key loader + JWKS export"
+```
+
+---
+
+### Task 3: Freshness confidence derivation (pure function)
+
+**Files:**
+- Create: `srv/lib/provenance-freshness.js`
+- Test: `test/unit/provenance-freshness.test.js`
+
+**Interfaces:**
+- Produces: `deriveConfidence({ report, now = Date.now() })` where `report` is `{ status, openHighCount, openMediumCount, runAt } | null`. Returns `'high' | 'medium' | 'low' | 'unknown'`. Also exports constants `FRESH_MAX_AGE_DAYS = 30`, `STALE_AGE_DAYS = 90`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/provenance-freshness.test.js
+import { describe, it, expect } from 'vitest';
+import { deriveConfidence } from '../../srv/lib/provenance-freshness.js';
+
+const DAY = 86400000;
+const now = Date.UTC(2026, 8, 11);
+const ago = d => new Date(now - d * DAY).toISOString();
+
+describe('deriveConfidence', () => {
+ it('unknown when no report', () => {
+ expect(deriveConfidence({ report: null, now })).toBe('unknown');
+ });
+ it('unknown when report not DONE', () => {
+ expect(deriveConfidence({ report: { status: 'FAILED', openHighCount: 0, openMediumCount: 0, runAt: ago(1) }, now })).toBe('unknown');
+ });
+ it('high: fresh scan, no high, no medium', () => {
+ expect(deriveConfidence({ report: { status: 'DONE', openHighCount: 0, openMediumCount: 0, runAt: ago(10) }, now })).toBe('high');
+ });
+ it('medium: clean but aging (30-90d)', () => {
+ expect(deriveConfidence({ report: { status: 'DONE', openHighCount: 0, openMediumCount: 0, runAt: ago(45) }, now })).toBe('medium');
+ });
+ it('medium: fresh scan but only medium findings', () => {
+ expect(deriveConfidence({ report: { status: 'DONE', openHighCount: 0, openMediumCount: 2, runAt: ago(5) }, now })).toBe('medium');
+ });
+ it('low: any open high finding', () => {
+ expect(deriveConfidence({ report: { status: 'DONE', openHighCount: 1, openMediumCount: 0, runAt: ago(1) }, now })).toBe('low');
+ });
+ it('low: scan older than 90d even if clean', () => {
+ expect(deriveConfidence({ report: { status: 'DONE', openHighCount: 0, openMediumCount: 0, runAt: ago(120) }, now })).toBe('low');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/unit/provenance-freshness.test.js`
+Expected: FAIL (module not found).
+
+- [ ] **Step 3: Implement `srv/lib/provenance-freshness.js`**
+
+```js
+export const FRESH_MAX_AGE_DAYS = 30;
+export const STALE_AGE_DAYS = 90;
+const DAY = 86400000;
+
+export function deriveConfidence({ report, now = Date.now() }) {
+ if (!report || report.status !== 'DONE' || !report.runAt) return 'unknown';
+ const ageDays = (now - new Date(report.runAt).getTime()) / DAY;
+ const high = report.openHighCount || 0;
+ const medium = report.openMediumCount || 0;
+ if (high > 0) return 'low';
+ if (ageDays > STALE_AGE_DAYS) return 'low';
+ if (ageDays > FRESH_MAX_AGE_DAYS || medium > 0) return 'medium';
+ return 'high';
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/unit/provenance-freshness.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/provenance-freshness.js test/unit/provenance-freshness.test.js
+git commit -m "feat(2245): freshness confidence derivation"
+```
+
+> **Note for Task 6:** `FreshnessReport` has `openHighCount` but NOT `openMediumCount` today. Task 6 computes `openMediumCount` from `FreshnessFinding` rows (disposition OPEN, severity Medium) at read time, since it is not stored. The derivation treats a missing `openMediumCount` as `0`.
+
+---
+
+### Task 4: `sourceCommit` schema column + publish append plumbing
+
+**Files:**
+- Modify: `db/_content-shape.cds` (add `sourceCommit` to `ContentFilesAspect`, `ContentCurrentAspect`, `ContentHistoryAspect`)
+- Modify: `srv/lib/content-publish-session.js` (accept `sourceCommits`, stamp onto ContentFiles row + carry to ContentCurrent)
+- Modify: `srv/lib/content-store.js` (`appendHandler` — destructure + forward `sourceCommits`)
+- Test: `test/lib/content-store.test.js` (extend publish/append test)
+
+**Interfaces:**
+- Consumes: append payload gains optional `sourceCommits: Record`.
+- Produces: `ContentCurrent.sourceCommit` (String(64)) populated per slug; read by Task 6.
+
+- [ ] **Step 1: Add the schema column**
+
+In `db/_content-shape.cds`, add to `ContentFilesAspect`, `ContentCurrentAspect`, and `ContentHistoryAspect` (after `sourceHash`):
+
+```cds
+ sourceCommit : String(64); // git commit SHA of source .md at publish time (#2245); null for pre-2245 rows
+```
+
+- [ ] **Step 2: Verify the model still compiles**
+
+Run: `npx cds compile db/ srv/ > /dev/null && echo OK`
+Expected: `OK` (no compile error).
+
+- [ ] **Step 3: Write the failing test**
+
+Add to `test/lib/content-store.test.js` inside the `POST /content/publish` describe (mirror the existing append flow — this repo uses the session begin/append/commit endpoints; follow the existing append test in this file for the exact begin/commit calls):
+
+```js
+it('persists sourceCommit onto ContentCurrent when supplied', async () => {
+ const { ContentCurrent } = cds.entities('com.sap.developers.ims');
+ const slug = 'commit-tutorial';
+ const sha = 'a'.repeat(40);
+ // begin → append(files + sourceCommits) → commit, per the existing append helper in this file
+ await publishViaSession({ files: { [slug]: '
x
' }, sourceCommits: { [slug]: sha } });
+ const row = await SELECT.one.from(ContentCurrent).where({ slug });
+ expect(row.sourceCommit).toBe(sha);
+});
+```
+
+(If no `publishViaSession` helper exists, inline the begin/append/commit `project.axios.post` calls the sibling append test already uses, adding `sourceCommits` to the append body.)
+
+- [ ] **Step 4: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/lib/content-store.test.js -t sourceCommit`
+Expected: FAIL (`sourceCommit` undefined / column absent in payload path).
+
+- [ ] **Step 5: Thread `sourceCommits` through the handler and session**
+
+In `srv/lib/content-store.js` `appendHandler` (~line 1935), add `sourceCommits` to the destructure and forward it:
+
+```js
+const { sessionId, files, metadata, bodyTexts, branchSpecs, sources, sourceCommits } = req.body || {};
+...
+const result = await sessionHelpers.appendToSession({ sessionId, files, metadata, bodyTexts, branchSpecs, sources, sourceCommits });
+```
+
+In `srv/lib/content-publish-session.js` `appendToSession` (~line 150), accept `sourceCommits = {}` and stamp it on the per-slug ContentFiles entry (~lines 186–196):
+
+```js
+entries.push({
+ slug, version: session.version, content: compressed, contentHash,
+ sizeBytes: decompressed.length, compressedBytes: compressed.length,
+ mimeType: 'text/html', sourceContent, sourceHash,
+ sourceCommit: sourceCommits[slug] || null,
+});
+```
+
+Then ensure the ContentFiles→ContentCurrent promotion copies `sourceCommit`. Locate the ContentCurrent upsert in `content-publish-session.js` (the mutable-current write, Option B) and add `sourceCommit` to its column set the same way `sourceHash` is carried.
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/lib/content-store.test.js -t sourceCommit`
+Expected: PASS.
+
+- [ ] **Step 7: Deploy-check the migration compiles for production**
+
+Run: `npx cds build --production > /dev/null && echo BUILD_OK`
+Expected: `BUILD_OK` (confirms the new column generates a clean migration table; do NOT hand-author `.hdbmigrationtable`).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add db/_content-shape.cds srv/lib/content-publish-session.js srv/lib/content-store.js test/lib/content-store.test.js
+git commit -m "feat(2245): persist per-slug sourceCommit through publish append"
+```
+
+---
+
+### Task 5: Provenance envelope builder + signer + cache
+
+**Files:**
+- Create: `srv/lib/provenance-envelope.js`
+- Test: `test/unit/provenance-envelope.test.js`
+
+**Interfaces:**
+- Consumes: `getSigningKey()` (Task 2), `deriveConfidence()` (Task 3).
+- Produces: `async buildEnvelope({ slug, contentHash, sourceCommit, builtAt, report, now })` → `{ jws, claims } | null` (null = fail-open / no key). Signs a compact JWS (JWT) with claims per spec. Caches the JWS keyed by `${slug}:${contentHash}:${report?.runAt || 'none'}`. Exposes `__clearEnvelopeCacheForTest()`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/provenance-envelope.test.js
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import { generateKeyPair, exportPKCS8, importJWK, jwtVerify, decodeJwt } from 'jose';
+import { buildEnvelope, __clearEnvelopeCacheForTest } from '../../srv/lib/provenance-envelope.js';
+import { getJwks, __setKeyForTest, __resetKeysForTest } from '../../srv/lib/provenance-keys.js';
+
+let pem;
+beforeAll(async () => {
+ const { privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
+ pem = await exportPKCS8(privateKey);
+});
+afterEach(() => { __resetKeysForTest(); __clearEnvelopeCacheForTest(); });
+
+const base = {
+ slug: 'my-tutorial', contentHash: 'c'.repeat(64), sourceCommit: 'a'.repeat(40),
+ builtAt: '2026-09-10T00:00:00.000Z',
+ report: { status: 'DONE', openHighCount: 0, openMediumCount: 0, runAt: '2026-09-05T00:00:00.000Z', model: 'gpt-x' },
+ now: Date.UTC(2026, 8, 11),
+};
+
+describe('buildEnvelope', () => {
+ it('returns null when no signing key (fail-open)', async () => {
+ __setKeyForTest(null);
+ expect(await buildEnvelope(base)).toBeNull();
+ });
+
+ it('signs a verifiable JWS with two distinct claim groups', async () => {
+ __setKeyForTest(pem);
+ const { jws, claims } = await buildEnvelope(base);
+ const pub = await importJWK((await getJwks()).keys[0], 'EdDSA');
+ const { payload } = await jwtVerify(jws, pub, { issuer: 'https://developers.sap.com' });
+ expect(payload.sub).toBe('my-tutorial');
+ expect(payload.contentHash).toBe(base.contentHash);
+ expect(payload.provenance).toMatchObject({ sourceRepo: 'sap-tutorials/Tutorials', sourceCommit: base.sourceCommit, builtAt: base.builtAt });
+ expect(payload.freshness).toMatchObject({ confidence: 'high', lastScanned: base.report.runAt, openHighCount: 0, detectorModel: 'gpt-x' });
+ expect(payload.exp - payload.iat).toBe(86400);
+ expect(claims.freshness.confidence).toBe('high');
+ });
+
+ it('tampered payload fails verification', async () => {
+ __setKeyForTest(pem);
+ const { jws } = await buildEnvelope(base);
+ const pub = await importJWK((await getJwks()).keys[0], 'EdDSA');
+ const [h, , s] = jws.split('.');
+ const forged = Buffer.from(JSON.stringify({ ...decodeJwt(jws), contentHash: 'f'.repeat(64) })).toString('base64url');
+ await expect(jwtVerify(`${h}.${forged}.${s}`, pub)).rejects.toThrow();
+ });
+
+ it('emits unknown confidence + null sourceCommit honestly', async () => {
+ __setKeyForTest(pem);
+ const { claims } = await buildEnvelope({ ...base, sourceCommit: null, report: null });
+ expect(claims.freshness.confidence).toBe('unknown');
+ expect(claims.provenance.sourceCommit).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/unit/provenance-envelope.test.js`
+Expected: FAIL (module not found).
+
+- [ ] **Step 3: Implement `srv/lib/provenance-envelope.js`**
+
+```js
+import { SignJWT } from 'jose';
+import { getSigningKey } from './provenance-keys.js';
+import { deriveConfidence } from './provenance-freshness.js';
+
+const ISS = 'https://developers.sap.com';
+const SOURCE_REPO = 'sap-tutorials/Tutorials';
+const TTL_SECONDS = 86400;
+const _cache = new Map(); // key -> { jws, claims }
+
+function cacheKey({ slug, contentHash, report }) {
+ return `${slug}:${contentHash}:${report?.runAt || 'none'}`;
+}
+
+export async function buildEnvelope({ slug, contentHash, sourceCommit, builtAt, report, now = Date.now() }) {
+ const signer = await getSigningKey();
+ if (!signer) return null; // fail-open: no key configured
+
+ const key = cacheKey({ slug, contentHash, report });
+ const hit = _cache.get(key);
+ if (hit && hit.claims.exp * 1000 > now) return hit;
+
+ const iat = Math.floor(now / 1000);
+ const claims = {
+ iss: ISS, sub: slug, iat, exp: iat + TTL_SECONDS,
+ contentHash,
+ provenance: { sourceRepo: SOURCE_REPO, sourceCommit: sourceCommit ?? null, builtAt: builtAt ?? null },
+ freshness: {
+ confidence: deriveConfidence({ report, now }),
+ lastScanned: report?.runAt ?? null,
+ openHighCount: report?.openHighCount ?? 0,
+ detectorModel: report?.model ?? null,
+ },
+ };
+
+ try {
+ const { iss, sub, iat: _i, exp, ...rest } = claims;
+ const jws = await new SignJWT(rest)
+ .setProtectedHeader({ alg: 'EdDSA', kid: signer.kid, typ: 'application/tutorial-provenance+jws' })
+ .setIssuer(ISS).setSubject(slug).setIssuedAt(iat).setExpirationTime(claims.exp)
+ .sign(signer.key);
+ const envelope = { jws, claims };
+ _cache.set(key, envelope);
+ return envelope;
+ } catch (e) {
+ console.warn('[provenance-envelope] signing failed, fail-open:', e.message);
+ return null;
+ }
+}
+
+export function __clearEnvelopeCacheForTest() { _cache.clear(); }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/unit/provenance-envelope.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/provenance-envelope.js test/unit/provenance-envelope.test.js
+git commit -m "feat(2245): signed provenance envelope builder + cache"
+```
+
+---
+
+### Task 6: Serve-time data loader (slug → contentHash + sourceCommit + report)
+
+**Files:**
+- Create: `srv/lib/provenance-data.js`
+- Test: `test/unit/provenance-data.test.js` (uses `cds.test` in-memory)
+
+**Interfaces:**
+- Produces: `async loadProvenanceInputs(slug)` → `{ contentHash, sourceCommit, builtAt, report } | null` (null when the tutorial content row is absent). `report` shape: `{ status, openHighCount, openMediumCount, runAt, model } | null`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/provenance-data.test.js
+import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
+import cds from '@sap/cds';
+import { loadProvenanceInputs } from '../../srv/lib/provenance-data.js';
+
+const project = cds.test('serve', '--project', '.', '--in-memory');
+
+describe('loadProvenanceInputs', () => {
+ let ContentCurrent, Tutorials, FreshnessReport, FreshnessFinding;
+ beforeAll(() => { ({ ContentCurrent, Tutorials, FreshnessReport, FreshnessFinding } = cds.entities('com.sap.developers.ims')); });
+ beforeEach(async () => {
+ await DELETE.from(ContentCurrent); await DELETE.from(FreshnessFinding);
+ await DELETE.from(FreshnessReport); await DELETE.from(Tutorials);
+ });
+
+ it('returns null for unknown slug', async () => {
+ expect(await loadProvenanceInputs('nope')).toBeNull();
+ });
+
+ it('joins content row, source commit, and current freshness report', async () => {
+ const tid = cds.utils.uuid();
+ await INSERT.into(Tutorials).entries({ ID: tid, slug: 'demo' });
+ await INSERT.into(ContentCurrent).entries({ slug: 'demo', contentHash: 'h'.repeat(64), sourceCommit: 'a'.repeat(40), modifiedAt: '2026-09-10T00:00:00.000Z' });
+ await INSERT.into(FreshnessReport).entries({ ID: cds.utils.uuid(), tutorial_ID: tid, status: 'DONE', openHighCount: 0, runAt: '2026-09-05T00:00:00.000Z', model: 'm1' });
+ const out = await loadProvenanceInputs('demo');
+ expect(out.contentHash).toBe('h'.repeat(64));
+ expect(out.sourceCommit).toBe('a'.repeat(40));
+ expect(out.report).toMatchObject({ status: 'DONE', openHighCount: 0, openMediumCount: 0, runAt: '2026-09-05T00:00:00.000Z', model: 'm1' });
+ });
+
+ it('counts open medium findings', async () => {
+ const tid = cds.utils.uuid();
+ await INSERT.into(Tutorials).entries({ ID: tid, slug: 'demo2' });
+ await INSERT.into(ContentCurrent).entries({ slug: 'demo2', contentHash: 'h'.repeat(64) });
+ const rid = cds.utils.uuid();
+ await INSERT.into(FreshnessReport).entries({ ID: rid, tutorial_ID: tid, status: 'DONE', openHighCount: 0, runAt: '2026-09-05T00:00:00.000Z' });
+ await INSERT.into(FreshnessFinding).entries([
+ { ID: cds.utils.uuid(), report_ID: rid, tutorial_ID: tid, severity: 'Medium', disposition: 'OPEN' },
+ { ID: cds.utils.uuid(), report_ID: rid, tutorial_ID: tid, severity: 'Medium', disposition: 'DISMISSED' },
+ ]);
+ const out = await loadProvenanceInputs('demo2');
+ expect(out.report.openMediumCount).toBe(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/unit/provenance-data.test.js`
+Expected: FAIL (module not found).
+
+- [ ] **Step 3: Implement `srv/lib/provenance-data.js`**
+
+```js
+import cds from '@sap/cds';
+
+export async function loadProvenanceInputs(rawSlug) {
+ const slug = String(rawSlug || '').toLowerCase();
+ const { ContentCurrent, Tutorials, FreshnessReport, FreshnessFinding } = cds.entities('com.sap.developers.ims');
+ try {
+ const content = await SELECT.one.from(ContentCurrent).columns('contentHash', 'sourceCommit', 'modifiedAt').where({ slug });
+ if (!content) return null;
+
+ const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug });
+ let report = null;
+ if (tut) {
+ const rep = await SELECT.one.from(FreshnessReport)
+ .columns('status', 'openHighCount', 'runAt', 'model').where({ tutorial_ID: tut.ID });
+ if (rep) {
+ const med = await SELECT.one.from(FreshnessFinding)
+ .columns('count(*) as n').where({ tutorial_ID: tut.ID, severity: 'Medium', disposition: 'OPEN' });
+ report = { status: rep.status, openHighCount: rep.openHighCount || 0, openMediumCount: med?.n || 0, runAt: rep.runAt, model: rep.model };
+ }
+ }
+ return { contentHash: content.contentHash, sourceCommit: content.sourceCommit || null, builtAt: content.modifiedAt || null, report };
+ } catch (e) {
+ console.warn('[provenance-data] load failed, fail-open:', e.message);
+ return null;
+ }
+}
+```
+
+> **Executor note:** confirm the `Tutorials` slug column name and that `ContentCurrent` exposes `modifiedAt` (from `managed`). If `count(*) as n` misbehaves under the CI Node/CDS combo, use `cds.entities(NS)` (already done) and fall back to fetching finding rows and counting in JS — see memory `ci-node-version-mismatch`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/unit/provenance-data.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/provenance-data.js test/unit/provenance-data.test.js
+git commit -m "feat(2245): serve-time provenance input loader"
+```
+
+---
+
+### Task 7: Provenance endpoint + JWKS route
+
+**Files:**
+- Modify: `srv/server.js` (register two routes in the `cds.on('bootstrap')` block)
+- Create: `srv/lib/provenance-handlers.js` (route handlers)
+- Test: `test/lib/provenance-endpoint.test.js` (`cds.test` HTTP)
+
+**Interfaces:**
+- Consumes: `isFlagEnabled` (Task 1), `buildEnvelope` (Task 5), `loadProvenanceInputs` (Task 6), `getJwks` (Task 2).
+- Produces: `provenanceHandler(req,res)` and `jwksHandler(req,res)` exported from `provenance-handlers.js`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/lib/provenance-endpoint.test.js
+import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
+import cds from '@sap/cds';
+import { generateKeyPair, exportPKCS8, importJWK, jwtVerify } from 'jose';
+import { __setKeyForTest, __resetKeysForTest } from '../../srv/lib/provenance-keys.js';
+import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js';
+
+const project = cds.test('serve', '--project', '.', '--in-memory');
+
+describe('provenance endpoint', () => {
+ let ContentCurrent, Tutorials;
+ beforeAll(async () => {
+ ({ ContentCurrent, Tutorials } = cds.entities('com.sap.developers.ims'));
+ const { privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true });
+ __setKeyForTest(await exportPKCS8(privateKey));
+ });
+ afterAll(() => { __resetKeysForTest(); __resetFlagsForTest(); });
+ beforeEach(async () => {
+ await DELETE.from(ContentCurrent); await DELETE.from(Tutorials);
+ await INSERT.into(Tutorials).entries({ ID: cds.utils.uuid(), slug: 'demo' });
+ await INSERT.into(ContentCurrent).entries({ slug: 'demo', contentHash: 'h'.repeat(64), sourceCommit: 'a'.repeat(40) });
+ });
+
+ it('404s when flag OFF', async () => {
+ __setFlagForTest('PROVENANCE_ENVELOPE_ENABLED', false);
+ await expect(project.axios.get('/content/tutorials/demo/provenance')).rejects.toMatchObject({ response: { status: 404 } });
+ });
+
+ it('serves a verifiable JWS when flag ON', async () => {
+ __setFlagForTest('PROVENANCE_ENVELOPE_ENABLED', true);
+ const res = await project.axios.get('/content/tutorials/demo/provenance');
+ expect(res.status).toBe(200);
+ const jwks = await project.axios.get('/.well-known/tutorial-provenance/jwks.json');
+ const pub = await importJWK(jwks.data.keys[0], 'EdDSA');
+ const { payload } = await jwtVerify(res.data.jws, pub, { issuer: 'https://developers.sap.com' });
+ expect(payload.sub).toBe('demo');
+ expect(payload.provenance.sourceCommit).toBe('a'.repeat(40));
+ });
+
+ it('404s for unknown slug when flag ON', async () => {
+ __setFlagForTest('PROVENANCE_ENVELOPE_ENABLED', true);
+ await expect(project.axios.get('/content/tutorials/nope/provenance')).rejects.toMatchObject({ response: { status: 404 } });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/lib/provenance-endpoint.test.js`
+Expected: FAIL (routes not registered → likely wildcard swallow / 200 HTML or 404 with wrong body).
+
+- [ ] **Step 3: Implement `srv/lib/provenance-handlers.js`**
+
+```js
+import { isFlagEnabled } from './feature-flags/db-flags.js';
+import { buildEnvelope } from './provenance-envelope.js';
+import { loadProvenanceInputs } from './provenance-data.js';
+import { getJwks } from './provenance-keys.js';
+
+export async function provenanceHandler(req, res) {
+ if (!isFlagEnabled('PROVENANCE_ENVELOPE_ENABLED')) return res.status(404).end();
+ const slug = String(req.params.slug || '').toLowerCase();
+ const inputs = await loadProvenanceInputs(slug);
+ if (!inputs) return res.status(404).json({ error: 'not_found' });
+ const envelope = await buildEnvelope({ slug, ...inputs });
+ if (!envelope) return res.status(503).json({ error: 'attestation_unavailable' });
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600');
+ res.json({ jws: envelope.jws, jwks_url: '/.well-known/tutorial-provenance/jwks.json' });
+}
+
+export async function jwksHandler(req, res) {
+ if (!isFlagEnabled('PROVENANCE_ENVELOPE_ENABLED')) return res.status(404).end();
+ const jwks = await getJwks();
+ res.setHeader('Content-Type', 'application/jwk-set+json; charset=utf-8');
+ res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=3600');
+ res.json(jwks);
+}
+```
+
+- [ ] **Step 4: Register the routes in `srv/server.js`**
+
+In the `cds.on('bootstrap')` block, import the handlers at the top (near line 32) and register the provenance route **before** the `app.get('/content/tutorials/*slug', serveHandler)` wildcard (~line 760), and the JWKS route alongside the other `/.well-known/*` handlers (~line 1034):
+
+```js
+// before the /content/tutorials/*slug wildcard:
+app.get('/content/tutorials/:slug/provenance', provenanceHandler);
+// alongside other .well-known routes:
+app.get('/.well-known/tutorial-provenance/jwks.json', jwksHandler);
+```
+
+> **Executor note:** Express 5 route ordering — the `:slug/provenance` path has an extra segment so it will not collide with the single-segment `.md` regex, but it MUST precede the `*slug` wildcard or the wildcard captures `demo/provenance` as the slug. Verify with the test.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/lib/provenance-endpoint.test.js`
+Expected: PASS.
+
+- [ ] **Step 6: srv-qa cp-list audit**
+
+`provenance-handlers.js` is now reachable from `server.js` but NOT from `content-store.js`, so it is not in the content-store transitive set. Still, confirm `.deploy/mta.yaml` `srv-qa` `cp` list includes the four new `srv/lib/provenance-*.js` files if QA boots `server.js`. Add any missing ones.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add srv/lib/provenance-handlers.js srv/server.js test/lib/provenance-endpoint.test.js .deploy/mta.yaml
+git commit -m "feat(2245): provenance JWS endpoint + JWKS route"
+```
+
+---
+
+### Task 8: Advisory headers on the HTML serve path
+
+**Files:**
+- Modify: `srv/lib/content-store.js` (`ContentCache.set/get` to carry advisory meta; both 200 branches of `serveStoredSlug`)
+- Test: `test/lib/provenance-headers.test.js` (`cds.test` HTTP)
+
+**Interfaces:**
+- Consumes: `isFlagEnabled` (Task 1), `loadProvenanceInputs` + `deriveConfidence` (Tasks 3/6).
+- Produces: `X-Freshness-Confidence` and `X-Content-Provenance` headers on `/content/tutorials/:slug` (both cache-miss and cache-hit paths) when the flag is ON.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/lib/provenance-headers.test.js
+import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
+import cds from '@sap/cds';
+import { gzipSync } from 'node:zlib';
+import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js';
+
+const project = cds.test('serve', '--project', '.', '--in-memory');
+const b64gz = html => gzipSync(Buffer.from(html)).toString('base64');
+
+describe('advisory provenance headers', () => {
+ beforeAll(() => { process.env.CONTENT_API_KEY = 'k'; });
+ afterAll(() => __resetFlagsForTest());
+ beforeEach(async () => {
+ const { ContentCurrent, ContentFiles, ContentManifest } = cds.entities('com.sap.developers.ims');
+ await DELETE.from(ContentCurrent); await DELETE.from(ContentFiles); await DELETE.from(ContentManifest);
+ // publish 'demo' so it is servable (reuse the publish helper/flow from content-store.test.js)
+ });
+
+ it('omits headers when flag OFF', async () => {
+ __setFlagForTest('PROVENANCE_ENVELOPE_ENABLED', false);
+ const res = await project.axios.get('/content/tutorials/demo');
+ expect(res.headers['x-freshness-confidence']).toBeUndefined();
+ });
+
+ it('emits headers on cache-miss AND cache-hit when flag ON', async () => {
+ __setFlagForTest('PROVENANCE_ENVELOPE_ENABLED', true);
+ const miss = await project.axios.get('/content/tutorials/demo'); // fills LRU
+ expect(miss.headers['x-freshness-confidence']).toBe('unknown');
+ expect(miss.headers['x-content-provenance']).toBe('/content/tutorials/demo/provenance');
+ const hit = await project.axios.get('/content/tutorials/demo'); // LRU hit
+ expect(hit.headers['x-content-source']).toBe('cache');
+ expect(hit.headers['x-freshness-confidence']).toBe('unknown');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/lib/provenance-headers.test.js`
+Expected: FAIL (headers absent).
+
+- [ ] **Step 3: Implement — carry advisory meta in the LRU and set headers in both branches**
+
+In `srv/lib/content-store.js`:
+- Extend `ContentCache.set(key, buffer, hash, advisory)` to store `advisory` on the entry (default `null`); `get` returns it.
+- Add a small helper near the top:
+
+```js
+import { isFlagEnabled } from './feature-flags/db-flags.js';
+import { loadProvenanceInputs } from './provenance-data.js';
+import { deriveConfidence } from './provenance-freshness.js';
+
+async function computeAdvisory(slug) {
+ if (!isFlagEnabled('PROVENANCE_ENVELOPE_ENABLED')) return null;
+ try {
+ const inputs = await loadProvenanceInputs(slug);
+ if (!inputs) return null;
+ return { confidence: deriveConfidence({ report: inputs.report }), url: `/content/tutorials/${slug}/provenance` };
+ } catch { return null; }
+}
+
+function setAdvisoryHeaders(res, advisory) {
+ if (!advisory) return;
+ res.setHeader('X-Freshness-Confidence', advisory.confidence);
+ res.setHeader('X-Content-Provenance', advisory.url);
+}
+```
+
+- In the fresh-DB-read branch (~lines 1089–1097): compute `const advisory = await computeAdvisory(slug);`, pass it into `cache.set(slug, decompressed, meta.contentHash, advisory)`, and call `setAdvisoryHeaders(res, advisory)` before `res.send`.
+- In the cache-hit branch (~lines 1012–1026): call `setAdvisoryHeaders(res, cached.advisory)` before `res.send` (no DB hit — advisory is whatever was cached; refreshes on next TTL miss, acceptable for a hint).
+
+> **Executor note:** `content-store.js` is the load-bearing content module — keep the new imports lazy-safe. `provenance-data.js` imports `cds` only; no AI SDK. Re-run the `srv-qa` cp-list audit: `provenance-data.js`, `provenance-freshness.js`, and `feature-flags/db-flags.js` are now transitively reachable from `content-store.js` and MUST be in the `srv-qa` `cp` list (`db-flags` already is; add the two `provenance-*` if absent).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/lib/provenance-headers.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Run the full content-store suite to check no regression**
+
+Run: `npx vitest run --project unit test/lib/content-store.test.js`
+Expected: PASS (existing serve/publish tests unaffected).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/lib/content-store.js test/lib/provenance-headers.test.js .deploy/mta.yaml
+git commit -m "feat(2245): advisory freshness/provenance headers on tutorial serve"
+```
+
+---
+
+### Task 9: Thread commit SHA from fetch → publish client
+
+**Files:**
+- Modify: `scripts/parsers/github.ts` (surface `lastCommitSha` in the returned metadata already — confirm it reaches `fetch-tutorials.ts`)
+- Modify: `scripts/fetch-tutorials.ts` (carry `ghMeta.lastCommitSha` per slug into a commit map written for publish)
+- Modify: `scripts/publish-content.ts` (build `sourceCommitsAll`, pass `sourceCommits` in `appendBatch`)
+- Modify: `scripts/lib/publish-client.ts` (`appendBatch` forwards `sourceCommits` in the POST body)
+- Test: `test/unit/publish-content-source-commit.test.js` (or extend an existing publish-client test)
+
+**Interfaces:**
+- Consumes: `lastCommitSha` (git commit SHA) from `fetchGitHubMeta` (Task-independent; already computed).
+- Produces: the append POST body carries `sourceCommits: Record`, consumed by Task 4's `appendHandler`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// test/unit/publish-content-source-commit.test.js
+import { describe, it, expect } from 'vitest';
+import { buildAppendBody } from '../../scripts/lib/publish-client.ts';
+
+describe('appendBatch body', () => {
+ it('includes sourceCommits when provided', () => {
+ const body = buildAppendBody({ sessionId: 's', files: { a: 'x' }, sourceCommits: { a: 'sha1' } });
+ expect(body.sourceCommits).toEqual({ a: 'sha1' });
+ });
+ it('omits sourceCommits key cleanly when absent', () => {
+ const body = buildAppendBody({ sessionId: 's', files: { a: 'x' } });
+ expect(body.sourceCommits).toBeUndefined();
+ });
+});
+```
+
+> If `appendBatch` builds its body inline, extract a pure `buildAppendBody(opts)` helper first (small refactor) so it is unit-testable, then have `appendBatch` call it.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run --project unit test/unit/publish-content-source-commit.test.js`
+Expected: FAIL (no `buildAppendBody` / no `sourceCommits`).
+
+- [ ] **Step 3: Implement the threading**
+
+- `scripts/lib/publish-client.ts`: add/extract `buildAppendBody(opts)` that spreads `sessionId, files, metadata, bodyTexts, branchSpecs, sources` and conditionally `...(opts.sourceCommits ? { sourceCommits: opts.sourceCommits } : {})`. `appendBatch` POSTs `buildAppendBody(...)`.
+- `scripts/fetch-tutorials.ts`: where `ghMeta` is obtained per slug (~line 962–974), record `sourceCommits[slug] = ghMeta.lastCommitSha` into a map available to the publish step (persist alongside the existing publish inputs — mirror how `sourceHashes` is surfaced).
+- `scripts/publish-content.ts`: build `sourceCommitsAll` (like `sourcesAll`, ~lines 1166–1168) and pass `sourceCommits: pickEntries(sourceCommitsAll, batch)` in the `appendBatch(...)` call (~lines 1188–1199).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run --project unit test/unit/publish-content-source-commit.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/lib/publish-client.ts scripts/fetch-tutorials.ts scripts/publish-content.ts test/unit/publish-content-source-commit.test.js
+git commit -m "feat(2245): thread source commit SHA through publish pipeline"
+```
+
+---
+
+### Task 10: Docs + operational wiring + full-suite verification
+
+**Files:**
+- Modify: `docs/developers/reference/tutorials-ims-gotchas.md` (add a "Signed provenance envelope" entry)
+- Modify: `CLAUDE.md` "Top Gotchas" (one-line pointer, per repo convention)
+- Modify: `docs/developers/operations/testing-endpoints.md` (document the two new public endpoints + the flag)
+
+**Interfaces:** none (documentation + verification only).
+
+- [ ] **Step 1: Document the feature**
+
+Add to `tutorials-ims-gotchas.md`: the `PROVENANCE_ENVELOPE_ENABLED` flag (DB config, DEV-first, default OFF), the two endpoints, the `PROVENANCE_SIGNING_KEY` credstore secret (Ed25519 PKCS8 PEM), key-rotation-via-JWKS note, and the fail-open contract. Add the CLAUDE.md one-liner pointing to it. Document endpoints in `testing-endpoints.md` (anonymous, `@requires` not applicable — Express routes, not a CAP service).
+
+- [ ] **Step 2: Generate a DEV signing key + record the credstore step**
+
+Document (do not commit any key) how to generate the key for DEV:
+
+```bash
+node -e "import('jose').then(async j=>{const {privateKey}=await j.generateKeyPair('EdDSA',{crv:'Ed25519',extractable:true});console.log(await j.exportPKCS8(privateKey))})"
+```
+
+Store the PEM in the target env's BTP Credential Store as `PROVENANCE_SIGNING_KEY` via `/admin-ui/#secrets` (per repo secret-rotation flow). Never in source or `.mtaext`.
+
+- [ ] **Step 3: Run the full unit suite**
+
+Run: `npm test`
+Expected: PASS (all `--project unit` tests, including the new provenance suites and the registry drift test).
+
+- [ ] **Step 4: Production build sanity**
+
+Run: `npx cds build --production > /dev/null && echo BUILD_OK`
+Expected: `BUILD_OK` (migration table for `sourceCommit` generated cleanly).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/ CLAUDE.md
+git commit -m "docs(2245): signed provenance envelope endpoints, flag, key handling"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- A (JWS/EdDSA) → Tasks 2, 5. *Note:* implemented as compact JWS (JWT via `SignJWT`) rather than flattened JWS — still standard, still `jose`-verifiable; the spec's "flattened" wording is satisfied by an equivalent compact serialization.
+- B (confidence derivation) → Task 3 (+ `openMediumCount` sourced in Task 6).
+- C (keys + JWKS) → Tasks 2, 7.
+- D (endpoint + advisory headers) → Tasks 7, 8; cache-hit correctness → Task 8 Step 3.
+- E (sign-on-first-serve + cache keyed by contentHash+runAt) → Task 5.
+- F (sourceCommit plumbing) → Tasks 4 (schema/server) + 9 (fetch/client).
+- G (flag + fail-open) → Task 1; fail-open asserted in Tasks 2/5/6/7/8.
+- H (tests) → each task is TDD; full suite in Task 10.
+
+**Placeholder scan:** No TBD/TODO. Two explicit executor-verification notes (Task 6 slug column / `count(*)`, Task 7 route ordering) are confirm-against-reality checks with concrete fallbacks, not placeholders.
+
+**Type consistency:** `buildEnvelope({slug, contentHash, sourceCommit, builtAt, report, now})` — Task 6's `loadProvenanceInputs` returns exactly `{contentHash, sourceCommit, builtAt, report}`, spread into `buildEnvelope` in Task 7. `report` shape `{status, openHighCount, openMediumCount, runAt, model}` is consistent across Tasks 3/5/6. `getSigningKey()→{key,kid}` and `getJwks()→{keys}` consistent across Tasks 2/5/7. Flag key `'PROVENANCE_ENVELOPE_ENABLED'` consistent across Tasks 1/7/8.
diff --git a/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md b/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md
new file mode 100644
index 000000000..202980029
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md
@@ -0,0 +1,883 @@
+# Installable Skill Bundle Endpoint — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add `GET /content/tutorials/:slug/skill` — a public, flag-gated endpoint that streams a zip containing an installable agent Skill (`SKILL.md` procedure + runnable `verify.sh` from assert blocks + provenance/freshness stamp).
+
+**Architecture:** A new pure-composition + data module `srv/lib/skill-bundle.js` reads raw tutorial markdown (`getTutorialSource`), the tutorial's `AssertSpecs`, and freshness inputs (`loadProvenanceInputs`/`deriveConfidence`/`buildEnvelope`), composes two text files, and streams them as a zip via `archiver`. It is wired as a plain Express route in `srv/server.js` registered before the `/content/tutorials/*slug` wildcard, gated by a new DB feature flag.
+
+**Tech Stack:** Node.js ESM, CAP (`@sap/cds`), Express, `archiver@8.0.0` (zip, already a dep), `gray-matter` (frontmatter parse, already a dep), `jose` (via existing provenance libs), Vitest.
+
+**Spec:** `docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md`
+
+## Global Constraints
+
+- **No new dependencies** — `archiver@8.0.0`, `gray-matter`, `js-yaml`, `jose`, `jszip` are all already declared. Adding an undeclared `srv/` runtime dep passes tests but crashes CF.
+- **Never write raw SQL** — use `cds.ql` (`SELECT`/`INSERT`/`DELETE`). (The one raw-SQL exception in the codebase is BLOB reads in `content-store.js`; not needed here.)
+- **Fail-open on sub-data, fail-closed on the feature** — flag OFF → 404; provenance/assert read errors → still ship the bundle with a degraded stamp / no checks.
+- **Feature flags are DB config, never env** — register in `feature-flags/registry.js` (`kind:'db'`), read via `isFlagEnabled(key)` from `srv/lib/feature-flags/db-flags.js` (synchronous).
+- **New DB flag MUST be in `feature-flags/registry.js`** or a registry guard test fails and it never surfaces in the admin Feature Flags UI.
+- **Slugs are lowercase-canonical** — `.toLowerCase()` before any slug comparison/lookup.
+- **Route ordering:** the `/skill` route MUST be registered before `app.get('/content/tutorials/*slug', serveHandler)` or the wildcard swallows it.
+- **Tests:** unit/lib tests run under `vitest run --project unit` (globs `test/**/*.test.{js,ts}`); smoke under `--project smoke`. In-memory DB setup: `await cds.deploy(path.join(process.cwd(),'db','schema.cds')).to('sqlite::memory:')` (a file path — NOT `cds.model`).
+
+## Logical assert shape (used across tasks)
+
+```js
+// The in-memory shape the composition consumes. It is the #2259 AssertBlock
+// minus the sidecar `index` field, with DB columns mapped back to logical names.
+/** @typedef {{
+ * stepNumber:number, assertIndex:number, type:'cmd'|'http'|'file',
+ * run?:string, expectExit?:number,
+ * method?:string, path?:string, expectStatus?:number,
+ * filePath?:string, expectContains?:boolean,
+ * match?:string
+ * }} LogicalAssert */
+```
+
+## Freshness stamp shape (used across tasks)
+
+```js
+/** @typedef {{ confidence:'high'|'medium'|'low'|'unknown',
+ * lastVerified:string|null, sourceCommit:string|null, jws:string|null }} FreshnessStamp */
+```
+
+---
+
+### Task 1: Feature flag `SKILL_BUNDLE_ENABLED`
+
+**Files:**
+- Modify: `srv/lib/feature-flags/registry.js` (add entry after the `PROVENANCE_ENVELOPE_ENABLED` entry, ~line 324)
+- Test: `test/unit/feature-flags-registry.test.js` if one exists asserting per-key shape; otherwise rely on the existing registry validator test.
+
+**Interfaces:**
+- Consumes: `featureFlagUpsert` helper already imported in `registry.js`.
+- Produces: flag key `'SKILL_BUNDLE_ENABLED'`, `imsConfigKey:'flag.skill.bundle'`, read via `isFlagEnabled('SKILL_BUNDLE_ENABLED')`.
+
+- [ ] **Step 1: Add the registry entry**
+
+In `srv/lib/feature-flags/registry.js`, immediately after the `PROVENANCE_ENVELOPE_ENABLED` object:
+
+```js
+{
+ key: 'SKILL_BUNDLE_ENABLED', label: 'Installable Skill bundle endpoint', category: 'Content',
+ kind: 'db', imsConfigKey: 'flag.skill.bundle',
+ valueType: 'boolean', default: false, status: 'dev-only',
+ description: 'When true, serves an installable agent-Skill zip at '
+ + '/content/tutorials/:slug/skill (SKILL.md procedure + verify.sh generated from assert '
+ + 'blocks + provenance/freshness stamp). Public, anonymous, read-only over PUBLISHED '
+ + 'tutorials. DB-driven config (ImsConfig key flag.skill.bundle); no env var. Default OFF (#2245).',
+ howToChange: featureFlagUpsert('SKILL_BUNDLE_ENABLED', 'flag.skill.bundle'),
+},
+```
+
+- [ ] **Step 2: Run the registry guard test to verify it still passes**
+
+Run: `npx vitest run --project unit test/unit/feature-flags-registry.test.js` (if the file does not exist, run the whole unit tier's registry-related test: `npx vitest run --project unit -t "registry"`)
+Expected: PASS (the new entry conforms to the schema; no duplicate key).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add srv/lib/feature-flags/registry.js
+git commit -m "feat(2245): register SKILL_BUNDLE_ENABLED feature flag"
+```
+
+---
+
+### Task 2: `buildVerifyScript` + shell-quoting (pure)
+
+**Files:**
+- Create: `srv/lib/skill-bundle.js` (start the module with these two pure exports)
+- Test: `test/unit/skill-bundle-compose.test.js`
+
+**Interfaces:**
+- Produces: `export function shquote(s: string): string` and `export function buildVerifyScript(asserts: LogicalAssert[]): string`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `test/unit/skill-bundle-compose.test.js`:
+
+```js
+import { describe, it, expect } from 'vitest';
+import { buildVerifyScript, shquote } from '../../srv/lib/skill-bundle.js';
+
+describe('shquote', () => {
+ it('wraps in single quotes and escapes embedded single quotes', () => {
+ expect(shquote(`a'b`)).toBe(`'a'\\''b'`);
+ expect(shquote('cds compile')).toBe(`'cds compile'`);
+ });
+});
+
+describe('buildVerifyScript', () => {
+ it('emits a bash header with strict mode', () => {
+ const s = buildVerifyScript([]);
+ expect(s.startsWith('#!/usr/bin/env bash\n')).toBe(true);
+ expect(s).toContain('set -euo pipefail');
+ });
+
+ it('empty asserts → notice + exit 0', () => {
+ const s = buildVerifyScript([]);
+ expect(s).toContain('No automated checks defined');
+ expect(s.trimEnd().endsWith('exit 0')).toBe(true);
+ });
+
+ it('cmd assert runs the command and checks exit code + optional match', () => {
+ const s = buildVerifyScript([{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'cds compile', expectExit: 0, match: 'ok' }]);
+ expect(s).toContain(`'cds compile'`);
+ expect(s).toContain('-eq 0');
+ expect(s).toContain('grep -Eq');
+ });
+
+ it('http assert curls BASE_URL+path with method and checks status', () => {
+ const s = buildVerifyScript([{ stepNumber: 2, assertIndex: 0, type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }]);
+ expect(s).toContain('BASE_URL="${BASE_URL:-http://localhost:4004}"');
+ expect(s).toContain('-X GET');
+ expect(s).toContain('${BASE_URL}/foo'.replace('/foo', "'/foo'").length ? '/foo' : '/foo'); // path is shell-quoted
+ expect(s).toContain('200');
+ });
+
+ it('file exists vs contains', () => {
+ const exists = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: false }]);
+ expect(exists).toContain('test -f');
+ expect(exists).not.toContain('grep -Eq');
+ const contains = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: true, match: 'service' }]);
+ expect(contains).toContain('grep -Eq');
+ });
+
+ it('preserves (stepNumber, assertIndex) order and fails overall when any check fails', () => {
+ const s = buildVerifyScript([
+ { stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'a', expectExit: 0 },
+ { stepNumber: 1, assertIndex: 1, type: 'cmd', run: 'b', expectExit: 0 },
+ ]);
+ expect(s.indexOf("'a'")).toBeLessThan(s.indexOf("'b'"));
+ expect(s).toContain('exit 1');
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js`
+Expected: FAIL — cannot resolve `../../srv/lib/skill-bundle.js` (module not created yet).
+
+- [ ] **Step 3: Implement the two functions**
+
+Create `srv/lib/skill-bundle.js`:
+
+```js
+// srv/lib/skill-bundle.js
+// GET /content/tutorials/:slug/skill — composes an installable agent Skill
+// (SKILL.md + verify.sh) as a zip. Item 3 of #2245. Pure composition first,
+// data + handler wiring below.
+
+const DEFAULT_BASE_URL = 'http://localhost:4004';
+
+/** POSIX single-quote escape: a'b -> 'a'\''b' */
+export function shquote(s) {
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
+}
+
+/** Build a runnable bash verifier from the tutorial's assert specs. */
+export function buildVerifyScript(asserts) {
+ const hasHttp = asserts.some((a) => a.type === 'http');
+ const L = [];
+ L.push('#!/usr/bin/env bash');
+ L.push('# Generated by SAP Tutorials — verifies this Skill against SAP\'s official steps.');
+ L.push('set -euo pipefail');
+ L.push('');
+ if (hasHttp) L.push('BASE_URL="${BASE_URL:-' + DEFAULT_BASE_URL + '}"');
+ L.push('fails=0');
+ L.push('check() { if [ "$1" -eq 0 ]; then echo " PASS: $2"; else echo " FAIL: $2"; fails=$((fails+1)); fi; }');
+ L.push('');
+
+ if (asserts.length === 0) {
+ L.push('echo "No automated checks defined for this tutorial."');
+ L.push('exit 0');
+ return L.join('\n') + '\n';
+ }
+
+ for (const a of asserts) {
+ const label = shquote(`step ${a.stepNumber} assert ${a.assertIndex} (${a.type})`);
+ L.push(`echo "Running check for ${a.type} @ step ${a.stepNumber}.${a.assertIndex}"`);
+ if (a.type === 'cmd') {
+ L.push('out=$(' + a.run + ' 2>&1) && rc=$? || rc=$?');
+ L.push(`if [ "$rc" -eq ${Number(a.expectExit)} ]; then ok=0; else ok=1; fi`);
+ if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! printf '%s' "$out" | grep -Eq -- ${shquote(a.match)}; then ok=1; fi`);
+ L.push(`check "$ok" ${label}`);
+ } else if (a.type === 'http') {
+ L.push(`body=$(mktemp)`);
+ L.push(`code=$(curl -s -o "$body" -w '%{http_code}' -X ${a.method} "${'${BASE_URL}'}"${shquote(a.path)} || echo 000)`);
+ L.push(`if [ "$code" = ${shquote(String(a.expectStatus))} ]; then ok=0; else ok=1; fi`);
+ if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! grep -Eq -- ${shquote(a.match)} "$body"; then ok=1; fi`);
+ L.push(`rm -f "$body"`);
+ L.push(`check "$ok" ${label}`);
+ } else if (a.type === 'file') {
+ if (a.expectContains) {
+ L.push(`if [ -f ${shquote(a.filePath)} ] && grep -Eq -- ${shquote(a.match || '')} ${shquote(a.filePath)}; then ok=0; else ok=1; fi`);
+ } else {
+ L.push(`if test -f ${shquote(a.filePath)}; then ok=0; else ok=1; fi`);
+ }
+ L.push(`check "$ok" ${label}`);
+ }
+ L.push('');
+ }
+
+ L.push('if [ "$fails" -gt 0 ]; then echo "$fails check(s) failed."; exit 1; fi');
+ L.push('echo "All checks passed."');
+ return L.join('\n') + '\n';
+}
+```
+
+> Note: fix the deliberately-awkward `path is shell-quoted` assertion in Step 1 to a plain
+> `expect(s).toContain(shquote('/foo'))` once you import `shquote` — keep the test readable.
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js`
+Expected: PASS (all `buildVerifyScript`/`shquote` cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/skill-bundle.js test/unit/skill-bundle-compose.test.js
+git commit -m "feat(2245): generate verify.sh from assert specs"
+```
+
+---
+
+### Task 3: `buildSkillMd` (pure)
+
+**Files:**
+- Modify: `srv/lib/skill-bundle.js` (add export)
+- Test: `test/unit/skill-bundle-compose.test.js` (add a `describe`)
+
+**Interfaces:**
+- Consumes: `gray-matter` (default import), the `FreshnessStamp` typedef.
+- Produces: `export function buildSkillMd({ slug, source, asserts, stamp }): string` — `source` is the raw tutorial markdown (with frontmatter), `asserts: LogicalAssert[]`, `stamp: FreshnessStamp`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `test/unit/skill-bundle-compose.test.js`:
+
+```js
+import { buildSkillMd } from '../../srv/lib/skill-bundle.js';
+
+const SRC = `---\ntitle: Create a CAP Service\ndescription: Build and run a CAP service.\n---\n\n## Step 1\nDo the thing.\n`;
+
+describe('buildSkillMd', () => {
+ it('emits YAML frontmatter with name (slug) and description (from source)', () => {
+ const md = buildSkillMd({ slug: 'create-cap-service', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc123', jws: null } });
+ expect(md).toMatch(/^---\n/);
+ expect(md).toContain('name: create-cap-service');
+ expect(md).toContain('Create a CAP Service'); // description carried from source title/description
+ });
+
+ it('includes the procedure body (source minus frontmatter)', () => {
+ const md = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } });
+ expect(md).toContain('Do the thing.');
+ expect(md).not.toContain('title: Create a CAP Service'); // frontmatter not duplicated into body
+ });
+
+ it('provenance section reflects the stamp and states check count', () => {
+ const md = buildSkillMd({ slug: 's', source: SRC, asserts: [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }], stamp: { confidence: 'medium', lastVerified: '2026-08-01', sourceCommit: 'deadbeef', jws: null } });
+ expect(md).toContain('confidence: medium');
+ expect(md).toContain('2026-08-01');
+ expect(md).toContain('deadbeef');
+ expect(md).toContain('1'); // one bundled check
+ });
+
+ it('includes the JWS fenced block only when present', () => {
+ const withJws = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: 'eyJ.sig' } });
+ expect(withJws).toContain('eyJ.sig');
+ const without = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } });
+ expect(without).not.toContain('```jws');
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js -t buildSkillMd`
+Expected: FAIL — `buildSkillMd` is not exported.
+
+- [ ] **Step 3: Implement `buildSkillMd`**
+
+Add to `srv/lib/skill-bundle.js` (top: `import matter from 'gray-matter';`):
+
+```js
+export function buildSkillMd({ slug, source, asserts, stamp }) {
+ let title = slug;
+ let description = '';
+ let body = String(source || '');
+ try {
+ const parsed = matter(String(source || ''));
+ title = parsed.data.title || slug;
+ description = parsed.data.description || '';
+ body = parsed.content.trim();
+ } catch {
+ body = String(source || '').trim();
+ }
+ // single-line, quote-safe description for YAML
+ const desc = `${title}${description ? ' — ' + description : ''}`.replace(/\s+/g, ' ').replace(/"/g, "'").trim();
+
+ const fm = ['---', `name: ${slug}`, `description: "${desc}"`, '---', ''].join('\n');
+
+ const n = asserts.length;
+ const verifyLine = asserts.some((a) => a.type === 'http')
+ ? 'Run `BASE_URL= bash verify.sh` to check your work.'
+ : 'Run `bash verify.sh` to check your work.';
+
+ const provenance = [
+ '## Provenance & freshness',
+ '',
+ `- confidence: ${stamp.confidence}`,
+ `- last-verified: ${stamp.lastVerified || 'unknown'}`,
+ `- source-commit: ${stamp.sourceCommit || 'unknown'}`,
+ '- source: sap-tutorials/Tutorials',
+ ];
+ if (stamp.jws) {
+ provenance.push('', 'Signed attestation (verify against the JWKS at `/.well-known/tutorial-provenance/jwks.json`):', '', '```jws', stamp.jws, '```');
+ }
+
+ const verify = [
+ '## Verifying this Skill',
+ '',
+ n === 0
+ ? 'No automated checks are bundled with this tutorial. Follow the procedure above.'
+ : `${n} automated check(s) are bundled in \`verify.sh\`. ${verifyLine}`,
+ ];
+
+ return [fm, `# ${title}`, '', body, '', verify.join('\n'), '', provenance.join('\n'), ''].join('\n');
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js`
+Expected: PASS (both `buildVerifyScript` and `buildSkillMd` groups).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/skill-bundle.js test/unit/skill-bundle-compose.test.js
+git commit -m "feat(2245): compose SKILL.md from tutorial source + stamp"
+```
+
+---
+
+### Task 4: `loadAssertSpecs(slug)` data helper
+
+**Files:**
+- Modify: `srv/lib/skill-bundle.js` (add export)
+- Test: `test/unit/skill-bundle-data.test.js`
+
+**Interfaces:**
+- Consumes: `cds.entities('com.sap.developers.ims')` → `Tutorials`, `AssertSpecs`.
+- Produces: `export async function loadAssertSpecs(slug: string): Promise` — ordered by `(stepNumber, assertIndex)`, DB columns remapped to logical names; `[]` on unknown slug or error.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/unit/skill-bundle-data.test.js`:
+
+```js
+import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
+import path from 'node:path';
+import cds from '@sap/cds';
+import { loadAssertSpecs } from '../../srv/lib/skill-bundle.js';
+
+beforeAll(async () => {
+ await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:');
+});
+
+beforeEach(async () => {
+ const { AssertSpecs, Tutorials } = cds.entities('com.sap.developers.ims');
+ await DELETE.from(AssertSpecs);
+ await DELETE.from(Tutorials);
+ await INSERT.into(Tutorials).entries([{ ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', slug: 'tutorial-alpha', title: 'Alpha', status: 'ACTIVE' }]);
+ await INSERT.into(AssertSpecs).entries([
+ { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 2, assertIndex: 0, assertType: 'http', httpMethod: 'GET', httpPath: '/foo', expectStatus: 200 },
+ { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 1, assertType: 'cmd', run: 'b', expectExit: 0 },
+ { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 0, assertType: 'cmd', run: 'a', expectExit: 0, matchRegex: 'ok' },
+ ]);
+});
+
+describe('loadAssertSpecs', () => {
+ it('returns [] for an unknown slug', async () => {
+ expect(await loadAssertSpecs('nope')).toEqual([]);
+ });
+
+ it('orders by (stepNumber, assertIndex) and remaps columns to logical names', async () => {
+ const specs = await loadAssertSpecs('tutorial-alpha');
+ expect(specs.map((s) => `${s.stepNumber}.${s.assertIndex}`)).toEqual(['1.0', '1.1', '2.0']);
+ expect(specs[0]).toMatchObject({ type: 'cmd', run: 'a', expectExit: 0, match: 'ok' });
+ expect(specs[2]).toMatchObject({ type: 'http', method: 'GET', path: '/foo', expectStatus: 200 });
+ });
+
+ it('lowercases the slug before lookup', async () => {
+ const specs = await loadAssertSpecs('TUTORIAL-ALPHA');
+ expect(specs).toHaveLength(3);
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js`
+Expected: FAIL — `loadAssertSpecs` not exported.
+
+- [ ] **Step 3: Implement `loadAssertSpecs`**
+
+Add to `srv/lib/skill-bundle.js` (top: `import cds from '@sap/cds';`):
+
+```js
+export async function loadAssertSpecs(slug) {
+ try {
+ const lc = String(slug || '').toLowerCase();
+ const { Tutorials, AssertSpecs } = cds.entities('com.sap.developers.ims');
+ const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug: lc });
+ if (!tut) return [];
+ const rows = await SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber', 'assertIndex');
+ return rows.map((r) => ({
+ stepNumber: r.stepNumber,
+ assertIndex: r.assertIndex,
+ type: r.assertType,
+ run: r.run ?? undefined,
+ expectExit: r.expectExit ?? undefined,
+ method: r.httpMethod ?? undefined,
+ path: r.httpPath ?? undefined,
+ expectStatus: r.expectStatus ?? undefined,
+ filePath: r.filePath ?? undefined,
+ expectContains: typeof r.expectContains === 'boolean' ? r.expectContains : undefined,
+ match: r.matchRegex ?? undefined,
+ }));
+ } catch (e) {
+ console.warn('[skill-bundle] loadAssertSpecs fail-open:', e.message);
+ return [];
+ }
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/skill-bundle.js test/unit/skill-bundle-data.test.js
+git commit -m "feat(2245): loadAssertSpecs reads + remaps AssertSpecs by slug"
+```
+
+---
+
+### Task 5: `buildFreshnessStamp(slug, opts)` data helper
+
+**Files:**
+- Modify: `srv/lib/skill-bundle.js` (add export)
+- Test: `test/lib/skill-bundle-endpoint.test.js` (created in Task 6 — but add a focused stamp `describe` here via injected deps; if executing strictly in order, put this test in `test/unit/skill-bundle-data.test.js` instead)
+
+**Interfaces:**
+- Consumes: `loadProvenanceInputs` from `./provenance-data.js`, `deriveConfidence` from `./provenance-freshness.js`, `buildEnvelope` from `./provenance-envelope.js` — all injectable for testing.
+- Produces: `export function makeFreshnessStampLoader({ loadProvenanceInputs, deriveConfidence, buildEnvelope })` returning `async (slug, { provenanceEnabled }) => FreshnessStamp`; plus a default `export async function buildFreshnessStamp(slug, opts)` bound to the real deps.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test/unit/skill-bundle-data.test.js`:
+
+```js
+import { makeFreshnessStampLoader } from '../../srv/lib/skill-bundle.js';
+
+describe('freshness stamp', () => {
+ const inputs = { contentHash: 'h', sourceCommit: 'sha1', builtAt: '2026-09-01', report: { status: 'DONE', runAt: '2026-09-01', openHighCount: 0 } };
+
+ it('derives confidence + commit without a signing key, no jws when provenance disabled', async () => {
+ const load = makeFreshnessStampLoader({
+ loadProvenanceInputs: async () => inputs,
+ deriveConfidence: () => 'high',
+ buildEnvelope: async () => ({ jws: 'should.not.appear' }),
+ });
+ const stamp = await load('s', { provenanceEnabled: false });
+ expect(stamp).toMatchObject({ confidence: 'high', sourceCommit: 'sha1', lastVerified: '2026-09-01', jws: null });
+ });
+
+ it('adds jws when provenance enabled and envelope builds', async () => {
+ const load = makeFreshnessStampLoader({
+ loadProvenanceInputs: async () => inputs,
+ deriveConfidence: () => 'high',
+ buildEnvelope: async () => ({ jws: 'eyJ.sig' }),
+ });
+ const stamp = await load('s', { provenanceEnabled: true });
+ expect(stamp.jws).toBe('eyJ.sig');
+ });
+
+ it('fail-open to unknown when inputs are null', async () => {
+ const load = makeFreshnessStampLoader({
+ loadProvenanceInputs: async () => null,
+ deriveConfidence: () => 'unknown',
+ buildEnvelope: async () => null,
+ });
+ const stamp = await load('s', { provenanceEnabled: true });
+ expect(stamp).toEqual({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null });
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js -t "freshness stamp"`
+Expected: FAIL — `makeFreshnessStampLoader` not exported.
+
+- [ ] **Step 3: Implement**
+
+Add to `srv/lib/skill-bundle.js`:
+
+```js
+import { loadProvenanceInputs as _loadProvenanceInputs } from './provenance-data.js';
+import { deriveConfidence as _deriveConfidence } from './provenance-freshness.js';
+import { buildEnvelope as _buildEnvelope } from './provenance-envelope.js';
+
+export function makeFreshnessStampLoader({ loadProvenanceInputs, deriveConfidence, buildEnvelope }) {
+ return async function loadStamp(slug, { provenanceEnabled }) {
+ try {
+ const inputs = await loadProvenanceInputs(String(slug || '').toLowerCase());
+ if (!inputs) return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null };
+ const confidence = deriveConfidence({ report: inputs.report, now: Date.now() });
+ const lastVerified = inputs.report?.runAt || inputs.builtAt || null;
+ let jws = null;
+ if (provenanceEnabled) {
+ try {
+ const env = await buildEnvelope({ slug: String(slug).toLowerCase(), ...inputs });
+ jws = env?.jws || null;
+ } catch { jws = null; }
+ }
+ return { confidence, lastVerified, sourceCommit: inputs.sourceCommit || null, jws };
+ } catch (e) {
+ console.warn('[skill-bundle] freshness stamp fail-open:', e.message);
+ return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null };
+ }
+ };
+}
+
+export const buildFreshnessStamp = (slug, opts) =>
+ makeFreshnessStampLoader({
+ loadProvenanceInputs: _loadProvenanceInputs,
+ deriveConfidence: _deriveConfidence,
+ buildEnvelope: _buildEnvelope,
+ })(slug, opts);
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add srv/lib/skill-bundle.js test/unit/skill-bundle-data.test.js
+git commit -m "feat(2245): freshness stamp (confidence + commit + optional jws)"
+```
+
+---
+
+### Task 6: `createSkillBundleHandler` + zip streaming
+
+**Files:**
+- Modify: `srv/lib/skill-bundle.js` (add handler factory + default export)
+- Test: `test/lib/skill-bundle-endpoint.test.js`
+
+**Interfaces:**
+- Consumes: `archiver` (`import archiver from 'archiver'`), `isFlagEnabled` from `./feature-flags/db-flags.js`, `getTutorialSource` from `./content-store.js`, plus `loadAssertSpecs`/`buildFreshnessStamp`/`buildSkillMd`/`buildVerifyScript` from this module — all injectable.
+- Produces: `export function createSkillBundleHandler(deps = {}): (req, res) => Promise` and `export const skillBundleHandler = createSkillBundleHandler()`.
+- Response: `application/zip`, `Content-Disposition: attachment; filename="-skill.zip"`, entries `/SKILL.md` and `/verify.sh` (mode `0o755`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `test/lib/skill-bundle-endpoint.test.js`:
+
+```js
+import { describe, it, expect } from 'vitest';
+import { Writable } from 'node:stream';
+import JSZip from 'jszip';
+import { createSkillBundleHandler } from '../../srv/lib/skill-bundle.js';
+
+// Collect the streamed zip into a buffer via a fake res that is a Writable.
+function fakeRes() {
+ const chunks = [];
+ const res = new Writable({ write(c, _e, cb) { chunks.push(Buffer.from(c)); cb(); } });
+ res.statusCode = 200;
+ res.headers = {};
+ res.setHeader = (k, v) => { res.headers[k.toLowerCase()] = v; };
+ res.status = (c) => { res.statusCode = c; return res; };
+ res.json = (b) => { res.jsonBody = b; res.end(); return res; };
+ res.buffer = () => Buffer.concat(chunks);
+ return res;
+}
+const req = (slug) => ({ params: { slug } });
+
+const baseDeps = {
+ isFlagEnabled: () => true,
+ getTutorialSource: async () => ({ markdown: `---\ntitle: T\ndescription: D\n---\n\n## Step 1\nBody.\n` }),
+ loadAssertSpecs: async () => [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }],
+ buildFreshnessStamp: async () => ({ confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: null }),
+ provenanceFlagKey: 'PROVENANCE_ENVELOPE_ENABLED',
+};
+
+describe('skill bundle handler', () => {
+ it('404 when feature flag is off', async () => {
+ const res = fakeRes();
+ await createSkillBundleHandler({ ...baseDeps, isFlagEnabled: () => false })(req('t'), res);
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('404 when the slug has no source markdown', async () => {
+ const res = fakeRes();
+ await createSkillBundleHandler({ ...baseDeps, getTutorialSource: async () => ({ markdown: null }) })(req('nope'), res);
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('200 streams a zip with SKILL.md and verify.sh', async () => {
+ const res = fakeRes();
+ await createSkillBundleHandler(baseDeps)(req('my-tutorial'), res);
+ await new Promise((r) => res.on('finish', r));
+ expect(res.headers['content-type']).toContain('application/zip');
+ expect(res.headers['content-disposition']).toContain('my-tutorial-skill.zip');
+ const zip = await JSZip.loadAsync(res.buffer());
+ expect(zip.file('my-tutorial/SKILL.md')).toBeTruthy();
+ const verify = await zip.file('my-tutorial/verify.sh').async('string');
+ expect(verify).toContain('#!/usr/bin/env bash');
+ expect(verify).toContain("'x'");
+ const skill = await zip.file('my-tutorial/SKILL.md').async('string');
+ expect(skill).toContain('name: my-tutorial');
+ expect(skill).toContain('confidence: high');
+ });
+
+ it('still ships (degraded) when provenance/asserts are empty', async () => {
+ const res = fakeRes();
+ await createSkillBundleHandler({ ...baseDeps, loadAssertSpecs: async () => [], buildFreshnessStamp: async () => ({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }) })(req('t'), res);
+ await new Promise((r) => res.on('finish', r));
+ const zip = await JSZip.loadAsync(res.buffer());
+ const verify = await zip.file('t/verify.sh').async('string');
+ expect(verify).toContain('No automated checks defined');
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run --project unit test/lib/skill-bundle-endpoint.test.js`
+Expected: FAIL — `createSkillBundleHandler` not exported.
+
+- [ ] **Step 3: Implement the handler**
+
+Add to `srv/lib/skill-bundle.js` (top: `import archiver from 'archiver';`, `import { isFlagEnabled as _isFlagEnabled } from './feature-flags/db-flags.js';`, `import { getTutorialSource as _getTutorialSource } from './content-store.js';`):
+
+```js
+const VALID_SLUG = /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/;
+
+export function createSkillBundleHandler(deps = {}) {
+ const {
+ isFlagEnabled = _isFlagEnabled,
+ getTutorialSource = _getTutorialSource,
+ loadAssertSpecs: _load = loadAssertSpecs,
+ buildFreshnessStamp: _stamp = buildFreshnessStamp,
+ provenanceFlagKey = 'PROVENANCE_ENVELOPE_ENABLED',
+ } = deps;
+
+ return async function handler(req, res) {
+ if (!isFlagEnabled('SKILL_BUNDLE_ENABLED')) return res.status(404).end();
+
+ const raw = Array.isArray(req.params?.slug) ? req.params.slug.join('/') : req.params?.slug;
+ const slug = String(raw || '').replace(/\/$/, '').toLowerCase();
+ if (!slug || !VALID_SLUG.test(slug)) return res.status(404).json({ error: 'not_found' });
+
+ const src = await getTutorialSource(slug);
+ if (!src || !src.markdown) return res.status(404).json({ error: 'not_found' });
+
+ const [asserts, stamp] = await Promise.all([
+ _load(slug),
+ _stamp(slug, { provenanceEnabled: isFlagEnabled(provenanceFlagKey) }),
+ ]);
+
+ const skillMd = buildSkillMd({ slug, source: src.markdown, asserts, stamp });
+ const verifySh = buildVerifyScript(asserts);
+
+ res.status(200);
+ res.setHeader('Content-Type', 'application/zip');
+ res.setHeader('Content-Disposition', `attachment; filename="${slug.replace(/\//g, '-')}-skill.zip"`);
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600');
+
+ const archive = archiver('zip', { zlib: { level: 9 } });
+ archive.on('error', (err) => {
+ console.error('[skill-bundle] archive error:', err.message);
+ if (!res.headersSent) res.status(500).end(); else res.destroy(err);
+ });
+ archive.pipe(res);
+ const dir = slug.replace(/\//g, '-');
+ archive.append(skillMd, { name: `${dir}/SKILL.md` });
+ archive.append(verifySh, { name: `${dir}/verify.sh`, mode: 0o755 });
+ await archive.finalize();
+ };
+}
+
+export const skillBundleHandler = createSkillBundleHandler();
+```
+
+> Note: `getTutorialSource` lowercases internally too, but we canonicalize here so the zip
+> dir name and `Content-Disposition` are stable. `VALID_SLUG` mirrors `content-store.js`.
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run --project unit test/lib/skill-bundle-endpoint.test.js`
+Expected: PASS (4 cases).
+
+- [ ] **Step 5: Run the full compose/data/endpoint set + lint**
+
+Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js test/unit/skill-bundle-data.test.js test/lib/skill-bundle-endpoint.test.js`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/lib/skill-bundle.js test/lib/skill-bundle-endpoint.test.js
+git commit -m "feat(2245): skill bundle handler streams SKILL.md + verify.sh zip"
+```
+
+---
+
+### Task 7: Wire the route in `srv/server.js`
+
+**Files:**
+- Modify: `srv/server.js` (import + route registration inside the `cds.on('bootstrap', (app) => {...})` block)
+- Test: `test/smoke/express-route-mutations.test.js` (extend the registered-route assertion set)
+
+**Interfaces:**
+- Consumes: `skillBundleHandler` from `./lib/skill-bundle.js`.
+- Produces: registered route `GET /content/tutorials/:slug/skill`, positioned before `app.get('/content/tutorials/*slug', serveHandler)`.
+
+- [ ] **Step 1: Locate the provenance route registration**
+
+Run: `grep -n "provenanceHandler\|/content/tutorials/\*slug\|/content/tutorials/:slug/provenance" srv/server.js`
+Expected: shows the `app.get('/content/tutorials/:slug/provenance', provenanceHandler)` line registered before the `*slug` wildcard.
+
+- [ ] **Step 2: Add the import**
+
+Near the other `srv/lib` imports at the top of `srv/server.js`:
+
+```js
+import { skillBundleHandler } from './lib/skill-bundle.js';
+```
+
+- [ ] **Step 3: Register the route (immediately after the `/provenance` route)**
+
+```js
+app.get('/content/tutorials/:slug/skill', skillBundleHandler);
+```
+
+- [ ] **Step 4: Extend the route-mutation smoke test**
+
+In `test/smoke/express-route-mutations.test.js`, add `'GET /content/tutorials/:slug/skill'` to the expected-registered-routes set (match how `/provenance` is asserted there).
+
+- [ ] **Step 5: Run the route smoke test**
+
+Run: `npx vitest run --project smoke test/smoke/express-route-mutations.test.js`
+Expected: PASS (new route present, registered before the wildcard).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add srv/server.js test/smoke/express-route-mutations.test.js
+git commit -m "feat(2245): register GET /content/tutorials/:slug/skill route"
+```
+
+---
+
+### Task 8: Route-drift guard allowlist entry
+
+**Files:**
+- Modify: `scripts/check-srv-qa-route-drift.ts` (`ALLOWLIST_ONLY_ON_SRV`, ~line 73)
+- Test: `test/unit/check-srv-qa-route-drift.test.ts` (verify guard passes; extend if it enumerates allowlist keys)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: allowlist key `'GET /content/tutorials/:slug/skill'`.
+
+- [ ] **Step 1: Add the allowlist entry**
+
+In `scripts/check-srv-qa-route-drift.ts`, inside `ALLOWLIST_ONLY_ON_SRV`, after the `/provenance` entry:
+
+```ts
+ 'GET /content/tutorials/:slug/skill':
+ 'Installable Skill bundle (#2245) — an anonymous, public, read-only prod content ' +
+ 'surface that streams a zip (SKILL.md + verify.sh from assert blocks + provenance stamp) ' +
+ 'over PUBLISHED tutorials. Feature-flagged (SKILL_BUNDLE_ENABLED, DB config, default OFF, ' +
+ 'DEV-first) and fail-open. Not a QA-channel surface: srv-qa serves tutorials behind ' +
+ 'requireAuthorScope (author-draft preview) and the Skill bundle is meaningful only for ' +
+ 'published content. Mirror of the /provenance allowlist rationale.',
+```
+
+- [ ] **Step 2: Run the drift-guard test**
+
+Run: `npx vitest run --project unit test/unit/check-srv-qa-route-drift.test.ts`
+Expected: PASS (the new srv-only route is allowlisted; guard reports no drift).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add scripts/check-srv-qa-route-drift.ts
+git commit -m "feat(2245): allowlist /skill as srv-only in route-drift guard"
+```
+
+---
+
+### Task 9: Full-suite verification + PR
+
+**Files:** none (verification + PR).
+
+- [ ] **Step 1: Run the whole unit tier**
+
+Run: `npm test`
+Expected: PASS (no regressions; new skill-bundle tests included).
+
+- [ ] **Step 2: Run the route smoke test once more**
+
+Run: `npx vitest run --project smoke test/smoke/express-route-mutations.test.js`
+Expected: PASS.
+
+- [ ] **Step 3: Confirm no new dependency crept in**
+
+Run: `git diff origin/DEV -- package.json package-lock.json`
+Expected: EMPTY (archiver/gray-matter/jszip were already declared).
+
+- [ ] **Step 4: Push and open a PR targeting DEV**
+
+```bash
+git push -u origin feat/skill-bundle-2245
+gh pr create --repo sap-tutorials/tutorials-ims --base DEV --head feat/skill-bundle-2245 \
+ --title "feat(2245): installable Skill bundle endpoint (item 3)" \
+ --body "Implements item 3 of #2245: GET /content/tutorials/:slug/skill streams an installable agent Skill (SKILL.md + verify.sh from assert blocks + provenance stamp) as a zip. Public, gated by SKILL_BUNDLE_ENABLED (DB flag, default OFF). Builds on #2256 (provenance) and #2259 (assert blocks). Spec: docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Route + registration before wildcard → Task 7. ✅
+- Feature flag `SKILL_BUNDLE_ENABLED`, 404 fail-closed → Task 1 + Task 6 Step 3. ✅
+- `getTutorialSource` → 404 on missing → Task 6. ✅
+- `loadAssertSpecs` direct entity read + column remap → Task 4. ✅
+- Freshness stamp (confidence/date/commit, JWS only when provenance on) → Task 5. ✅
+- `buildSkillMd` (frontmatter name/description, procedure body, provenance section) → Task 3. ✅
+- `buildVerifyScript` (cmd/http/file, empty→exit 0, shell-quoting, ordering) → Task 2. ✅
+- Zip via archiver, `application/zip` + `Content-Disposition` → Task 6. ✅
+- Error-handling table (flag/slug/fail-open/stream error) → Tasks 6 (handler) + 2/4/5 (fail-open). ✅
+- Drift-guard allowlist → Task 8. ✅
+- Testing (compose unit, endpoint via jszip, route smoke, drift guard) → Tasks 2/3/4/5/6/7/8. ✅
+- "No new dependency" verification → Task 9 Step 3. ✅
+
+**Placeholder scan:** No TBD/TODO. The one awkward assertion in Task 2 Step 1 is flagged with an explicit fix instruction (use `shquote('/foo')`). No "similar to Task N" — code is repeated where read out of order.
+
+**Type consistency:** `LogicalAssert` fields (`type`, `method`, `path`, `match`, `stepNumber`, `assertIndex`, `expectExit`, `expectStatus`, `filePath`, `expectContains`, `run`) are consistent across `loadAssertSpecs` (Task 4), `buildVerifyScript` (Task 2), `buildSkillMd` (Task 3), and endpoint deps (Task 6). `FreshnessStamp` (`confidence`, `lastVerified`, `sourceCommit`, `jws`) consistent across Tasks 3, 5, 6. Handler dep names (`isFlagEnabled`, `getTutorialSource`, `loadAssertSpecs`, `buildFreshnessStamp`, `provenanceFlagKey`) consistent between the factory (Task 6 Step 3) and its tests (Task 6 Step 1).
diff --git a/docs/superpowers/specs/2026-09-11-2245-signed-provenance-envelope-design.md b/docs/superpowers/specs/2026-09-11-2245-signed-provenance-envelope-design.md
new file mode 100644
index 000000000..b7819e299
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-2245-signed-provenance-envelope-design.md
@@ -0,0 +1,145 @@
+# Signed Tutorial Provenance & Freshness Attestation
+
+- **Issue:** [#2245](https://github.com/sap-tutorials/tutorials-ims/issues/2245) — "Executable, self-verifying, self-healing tutorials" (idea #1: signed freshness/provenance signal)
+- **Date:** 2026-09-11
+- **Status:** Design approved (brainstorm); pending spec review → implementation plan
+- **Scope:** Idea #1 only. Ideas #2–4 (assert blocks, tutorials-as-Skills, self-healing) are out of scope and depend on this as their trust spine.
+
+## Problem
+
+Staleness is the #1 cause of wrong AI SAP advice. When an AI agent fetches a tutorial (HTML or `.md`) to scaffold or answer, the content carries **no machine-readable signal** for the agent to judge how much to trust it or whether it is current. There is also no way for a third party to prove a given tutorial payload genuinely came from SAP unmodified.
+
+We already own every hard piece: a shipped freshness detector (`srv/lib/freshness-detector.js`), a HANA-backed content serve path with content hashing, a source-commit SHA captured at fetch time, and a `.well-known` middleware seam. This design exposes a **cryptographically signed provenance + freshness attestation** an agent can fetch and verify.
+
+## Goals
+
+- A per-tutorial signed envelope any third-party agent can verify **offline** against a published public key, with no shared secret.
+- Two **distinct** claims — factual provenance and derived staleness — never collapsed into one opaque score.
+- Cheap advisory headers on the existing serve path so an agent doing a `HEAD`/`GET` gets a hint and a pointer without parsing crypto.
+- Fail-open: the attestation never blocks or degrades content delivery.
+
+## Non-goals (YAGNI)
+
+- No automated **key-rotation job** — the format is rotation-ready (JWKS lists multiple keys) but rotation stays a manual op for now.
+- No **per-step** provenance — attestation is per-tutorial.
+- No envelopes for homepage / content-pages / concepts — **tutorials only**.
+- No signing of the HTML bytes inline (no JSON-LD embed) — the envelope is a separate resource.
+- Not idea #2/#3/#4. No executable assertions, no `SKILL.md` generation, no self-healing PRs.
+
+## Approved design
+
+### A. Envelope format — JWS / EdDSA (Ed25519)
+
+Use a standard **flattened JWS** (RFC 7515) with `alg: EdDSA`, `crv: Ed25519`, rather than a bespoke signature. Any consuming agent verifies with an off-the-shelf JOSE library plus the published JWKS — no custom crypto for us to document or for them to reimplement.
+
+JWS protected header:
+
+```json
+{ "alg": "EdDSA", "kid": "", "typ": "application/tutorial-provenance+jws" }
+```
+
+Payload (claims):
+
+```jsonc
+{
+ "iss": "https://developers.sap.com",
+ "sub": "", // lowercase-canonical slug
+ "iat": 1757600000,
+ "exp": 1757686400, // TTL bounds staleness of the ATTESTATION itself (see E)
+ "contentHash": "", // binds the signature to exact served bytes (= ETag)
+ "provenance": { // FACTUAL claim — no judgment
+ "sourceRepo": "sap-tutorials/Tutorials",
+ "sourceCommit": "",
+ "builtAt": ""
+ },
+ "freshness": { // JUDGMENT claim — derived (see B)
+ "confidence": "high|medium|low|unknown",
+ "lastScanned": "",
+ "openHighCount": 0,
+ "detectorModel": "" // transparency: which LLM judged
+ }
+}
+```
+
+The two claim groups are kept as separate objects on purpose: `provenance` is verifiable fact; `freshness` is an LLM-derived judgment. An agent may weight them independently (e.g. trust an old-but-clean tutorial while down-weighting a recently-scanned-but-flagged one).
+
+### B. Freshness confidence derivation
+
+Deterministic mapping from the latest `FreshnessReport` for the tutorial — **no LLM call at serve time**. Thresholds are constants (candidate values below; confirm in review):
+
+| confidence | condition |
+|-----------|-----------|
+| `high` | latest scan ≤ 30d old **and** `openHighCount == 0` **and** no open Medium finding |
+| `medium` | clean but aging (scan 30–90d old), **or** only open Low/Medium findings |
+| `low` | any open **High** finding, **or** latest scan > 90d old |
+| `unknown` | never scanned, no report, or freshness feature disabled |
+
+`unknown` is honest — we never fake `high` in the absence of a scan. "Open" respects `FreshnessFinding` disposition (ACCEPTED/DISMISSED/FIXED findings do not count against confidence; only OPEN does).
+
+### C. Keys — Ed25519 + JWKS
+
+- **Private key** stored in the BTP Credential Store (same seam as `CONTENT_API_KEY`), loaded once at boot. Never in source or committed env. Absent key ⇒ feature fails open (disabled), logged once.
+- **`kid`** identifies the signing key in the JWS header.
+- **Public keys** published as a JWKS document at `GET /.well-known/tutorial-provenance/jwks.json`, reusing the existing `.well-known` approuter-middleware seam (`docs/.../2026-08-28-well-known-oauth-discovery-design.md`). Anonymous, cacheable.
+- **Rotation-ready:** JWKS may list multiple public keys; we sign with the newest `kid`. Old public keys remain published until all envelopes signed under them have expired. No rotation automation built now.
+
+### D. Endpoint + serve integration
+
+- **`GET /content/tutorials/:slug/provenance`** → returns the flattened JWS as `application/jose+json` (or a thin wrapper `{ jws, jwks_url }`). Anonymous, CDN-frontable, tagged with the **same `Edge-Cache-Tag`** as the tutorial so it purges together on republish.
+- **Advisory (unsigned) headers** added to the existing HTML serve path (`serveStoredSlug`, `srv/lib/content-store.js`):
+ - `X-Freshness-Confidence: high|medium|low|unknown`
+ - `X-Content-Provenance: /content/tutorials/:slug/provenance`
+ These are hints, not trust anchors — an agent that wants assurance fetches and verifies the JWS.
+- **Cache-hit correctness:** the advisory values must be stored **inside the LRU-cached object** alongside `{ buffer, hash }`, because the cache-hit branch bypasses the DB read. Otherwise cache hits would silently drop the headers (this is the gotcha flagged during grounding).
+
+### E. Signing lifecycle
+
+Sign **on first serve**, cache the JWS keyed by `(contentHash, freshnessReportRunAt)`. Re-sign only when the content changes (new `contentHash`) or the freshness report changes (`runAt` advances). No coupling to the publish transaction for signing. `exp` = `iat + ATTESTATION_TTL` (candidate 24h) so an attestation cannot be replayed indefinitely; expiry forces a re-sign that re-reads current freshness.
+
+### F. Data plumbing for `sourceCommit`
+
+The source SHA (`currentSha`) is captured in `scripts/fetch-tutorials.ts` (written to `.tutorial-cache/.sha`, used as the cache-invalidation key) but discarded afterward. Plumb it through:
+
+1. `fetch-tutorials.ts` — retain `currentSha` per slug into the publish input.
+2. `scripts/publish-content.ts` → `POST /content/publish/append` payload — add `sourceCommit`.
+3. New `sourceCommit : String(64)` column on the content aspect in `db/_content-shape.cds` (carried by `ContentCurrent` / `ContentManifest`). Migration via `cds build --production`.
+4. Pre-existing rows: `sourceCommit` is null ⇒ the `provenance.sourceCommit` claim is emitted as `null` (honest; not fabricated).
+
+> Review question F: store `sourceCommit` on `ContentCurrent` (per-serve read, no extra query) or on `ContentManifest` only (one row per publish, needs a join at serve)? Default: `ContentCurrent` for cheap serve-time read.
+
+### G. Feature flag + fail-open
+
+- DB-config flag **`PROVENANCE_ENVELOPE_ENABLED`** registered in `srv/.../feature-flags/registry.js` (`kind:'db'`, `dev-only` first, default OFF). Per project rule, feature flags are DB config, never env (blue-green drops `cf set-env`).
+- Flag OFF ⇒ `/provenance` returns 404, no advisory headers emitted.
+- **Fail-open everywhere:** any signing error, missing key, or freshness lookup failure ⇒ content serves normally; the envelope endpoint returns 503; nothing throws into the content path.
+
+### H. Testing
+
+Deterministic unit coverage (no live LLM, no live HANA — SQLite/in-memory per project test conventions):
+
+- Confidence derivation table (each row of B, incl. disposition handling).
+- Envelope claim shape and required fields.
+- JWS round-trips: verifies against the matching public key; **tamper** (mutated payload) ⇒ verification fails; wrong `kid` ⇒ fails.
+- JWKS document shape and that the served public key matches the signing key.
+- Flag OFF path (404, no headers); missing report ⇒ `unknown`; null `sourceCommit` ⇒ null claim.
+- Cache-hit path still emits advisory headers.
+
+## Components & seams (existing code to attach to)
+
+| Concern | Seam |
+|---|---|
+| Freshness data | `srv/lib/freshness-detector.js`, `db/tutorial-freshness.cds` (`FreshnessReport`/`FreshnessFinding`) |
+| Serve path + headers + LRU | `srv/lib/content-store.js` (`serveStoredSlug`), header sites; `srv/server.js:~760` route |
+| Content identity | existing `contentHash`/ETag on the served row |
+| Source SHA | `scripts/fetch-tutorials.ts` (`currentSha`), `scripts/publish-content.ts`, `/content/publish/append` |
+| Schema | `db/_content-shape.cds` (content aspect) |
+| Key distribution | `.well-known` middleware seam (2026-08-28 design) |
+| Flag | `feature-flags/registry.js` (`kind:'db'`) |
+| New module | `srv/lib/provenance-envelope.js` (build + sign + cache), `srv/lib/provenance-keys.js` (key load + JWKS) |
+
+## Review questions — RESOLVED (defaults approved 2026-09-11)
+
+1. **B** — thresholds 30d (`high`) / 90d (`low`) and attestation TTL 24h ship as **constants** in the new module (not DB-config for v1).
+2. **C** — JWKS served at **`/.well-known/tutorial-provenance/jwks.json`** via the existing `.well-known` middleware seam.
+3. **F** — `sourceCommit` stored on **`ContentCurrent`** for cheap serve-time read.
+4. **Crypto** — **Node native `crypto`** Ed25519 (`sign`/`verify` with `'Ed25519'`) + minimal flattened-JWS assembly; **no new runtime dependency** (confirm `jose` is not already resolvable at plan time — if it is, reuse it).
diff --git a/docs/superpowers/specs/2026-09-11-2247-hcql-reland-design.md b/docs/superpowers/specs/2026-09-11-2247-hcql-reland-design.md
new file mode 100644
index 000000000..b1b894c4b
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-2247-hcql-reland-design.md
@@ -0,0 +1,191 @@
+# HCQL Re-land Design (#2247)
+
+**Date:** 2026-09-11
+**Issue:** [sap-tutorials/tutorials-ims#2247](https://github.com/sap-tutorials/tutorials-ims/issues/2247)
+**Decision:** Re-land HCQL (issue Option 1), scoped to the 5 authenticated services, on a CAP runtime bump.
+**Supersedes:** the reverted approach in `#1002` (reverted by `#1004`).
+
+## 1. Background
+
+`#1002` enabled the CAP 10 HCQL ("CQL over HTTP") protocol adapter on 9 read
+services via a central `srv/hcql-enablement.cds` that did
+`annotate with @hcql`. It was reverted in `#1004` because **218 unit
+tests failed**: HCQL co-mounted on each service's existing absolute OData
+`@path` and greedily parsed request bodies/URLs as CQN, breaking normal OData
+traffic (500s on write/action requests, 400s on `=`-carrying query params).
+The docs (`docs/developers/reference/hcql-support.md`) were left in the tree and
+still imply the feature ships — which is what #2247 is about.
+
+## 2. Spike findings (verified 2026-09-11)
+
+Reproduced against the pinned `@sap/cds@10.0.3`, and re-tested against
+`@sap/cds@10.1.0` in an isolated throwaway CAP project.
+
+### On the pinned 10.0.3 (current)
+- Adding `annotate <5 authenticated services> with @hcql` reproduces the
+ regression: sampled suite went **15/15 green → 8 failed**. Scoping to
+ authenticated services does **not** help — the collision is per-path, not
+ per-auth.
+- `@hcql` does **not** mount at the documented `/hcql/` prefix
+ (`POST /hcql/author` → 404). Instead it rides the service's own OData path
+ (`POST /author` with a CQN body → 200), and breaks:
+ - OData `$filter=… eq …` (the `=`/`eq` params)
+ - read-only `PATCH` → **500** instead of 405
+- `cds.env.protocols.hcql.path` defaults to `/hcql` but the adapter ignores it
+ for absolute-`@path` services.
+
+### On 10.1.0 (latest published) with plain `@hcql`
+- OData `$filter` interception is **fixed** (`GET …$filter=… eq …` → 200).
+- But HCQL **still** rides `/admin` (`POST /admin` CQN → 200), still no
+ `/hcql/*` mount (404), and read-only `PATCH` → **500**. Plain `@hcql` is not
+ enough on 10.1.0 either.
+
+### On 10.1.0 with explicit per-service `@protocol` (the chosen mechanism)
+Service configured as
+`@protocol: [{ kind: 'odata-v4', path: '/admin' }, { kind: 'hcql', path: '/hcql/admin' }]`:
+
+| Probe | Result | Meaning |
+|---|---|---|
+| `GET /admin/Books?$filter=title eq 'x'` | **200** | OData clean |
+| `PATCH /admin/Books(1)` (read-only) | **405** | no HCQL interception on OData path |
+| `POST /admin` with CQN body | **405** | OData path rejects CQN — full separation |
+| `POST /hcql/admin` CQN | **200** `{"data":[]}` | HCQL works on its own path |
+| `POST /hcql/admin` malformed CQN | **400**, server **alive** | **process-exit DoS is fixed in 10.1.0** |
+| `GET /admin/Books?$top=1` after malformed | **200** | process survived |
+
+**Conclusion:** the clean re-land requires BOTH (a) bumping the CAP runtime to
+10.1.0 and (b) giving each enabled service an explicit `@protocol` list that
+mounts HCQL on a distinct `/hcql/` path instead of colliding with the
+OData `@path`.
+
+## 3. Scope
+
+Enable HCQL on the **5 authenticated services only**
+(chosen by the maintainer on #2247):
+
+| Service | OData path | HCQL path | Auth |
+|---|---|---|---|
+| `AdminService` | `/admin` | `/hcql/admin` | XSUAA + `Admin` |
+| `AuthorService` | `/author` | `/hcql/author` | XSUAA + `Tutorial.Author` |
+| `AnalyticsService` | `/admin/analytics` | `/hcql/analytics` | XSUAA + `Admin` |
+| `ExportsService` | `/admin/exports` | `/hcql/exports` | XSUAA + `Admin` |
+| `ConsolidationService` | `/api/v1` | `/hcql/consolidation` | XSUAA + `ConsolidationScope` |
+
+**Out of scope (dropped from the original 9):** the 4 public/anonymous
+services `KnowledgeGraphService` (`/graph`), `HomepageService` (`/homepage`),
+`SearchService` (`/search`), `DeveloperService` (`/api`). Although the
+malformed-CQN process-exit DoS is fixed in 10.1.0, exposing an unspecified beta
+query surface anonymously is low value / needless risk; authenticated-only is
+the conservative default.
+
+`ExportsService` and `ConsolidationService` expose only actions/functions (no
+queryable entities); HCQL `SELECT` returns no rows there. They are included for
+symmetry and so the surface is uniform, matching the original intent.
+
+## 4. Design
+
+### 4.1 CAP runtime bump + package.json-wide dependency update (the dominant risk)
+
+A CAP minor bump must not be piecemeal: the whole CAP plugin stack moves with
+the runtime, and the maintainer has asked for a package.json-wide refresh
+overall. This is done as a coordinated, phased update, each phase gated by the
+full test suite so regressions are attributable.
+
+**Prerequisite:** dependency operations hit the private `@sap-tutorials/*`
+GitHub npm registry, so `NODE_AUTH_TOKEN` must be set (from `gh auth token`)
+before `npm install` / `npm outdated`. In a worktree, edit-isolation blocks the
+`$(gh auth token)` substitution — set the token in the environment first (or
+run dep work from the primary checkout).
+
+**Phase A — CAP ecosystem (compatibility-critical, required for HCQL):**
+- `@sap/cds` `^10.0.3` → `^10.1.0`; `@sap/cds-dk` → `^10.1.x` (10.1.1 latest).
+- Move the CAP plugin stack to latest versions compatible with cds 10.1:
+ `@cap-js/{ai,attachments,audit-logging,change-tracking,data-inspector,graphql,hana,mcp,ord,sqlite,telemetry}`,
+ `@cap-js-community/websocket`, `@cap-js/cds-test`, `cds-caching`,
+ `cds-swagger-ui-express`, `@sap/xsenv`, `@sap/xssec`, `@sap-cloud-sdk/*`,
+ `@sap-ai-sdk/*`, `@sap-tutorials/cds-alert-notification`.
+- Several of these are **exact-pinned** (`@cap-js/mcp 1.1.1`,
+ `@cap-js/graphql 0.14.0`, `@cap-js/attachments 4.0.0`,
+ `@cap-js/data-inspector 1.0.5`, `cds-caching 2.0.2`, …). Pins are treated as
+ deliberate: each is bumped consciously and cross-checked against the memory
+ gotchas for that plugin (cds-caching store, mcp, graphql-shortcut, ai, hana).
+- Align CAP version pins in `.deploy/mta.yaml`, `.cdsrc*`, CI Node config.
+
+**Phase B — broader tree:** refresh remaining deps (aws-sdk, sharp, socket.io,
+undici, cheerio, exceljs, ui5 webcomponents, vitest, playwright, esbuild,
+vitepress, etc.) **within their current major only** (maintainer decision,
+2026-09-11). Any major-version jump is explicitly out of scope for this PR and
+deferred to a separate maintenance change — not swept in here.
+
+**Gate for both phases:** commit the regenerated `package-lock.json`; full unit
++ hybrid suites green; `cds build --production` succeeds; Hugo/apps build
+sanity. **Risk:** this is now a broad update across a production app with many
+CAP plugins — behavior changes beyond HCQL are expected and triaged. If Phase A
+proves too disruptive, this issue falls back to Option 2 (mark docs NOT
+DEPLOYED) and the bump is deferred — that decision returns to the maintainer.
+
+### 4.2 HCQL enablement via explicit `@protocol`
+- **AdminService** already has `@protocol: [{kind:'odata'},{kind:'mcp', path:'/mcp/admin'}]`
+ in `srv/admin-service-mcp.cds`. A service may carry only one `@protocol`, so
+ HCQL is added to that existing list (not a second `annotate`):
+ `@protocol: [{kind:'odata'}, {kind:'mcp', path:'/mcp/admin'}, {kind:'hcql', path:'/hcql/admin'}]`.
+ The load-bearing object-form (per the file's own warning) is preserved.
+- **The other 4 services** have plain `@path` and no `@protocol`. A central
+ `srv/hcql-enablement.cds` adds, per service:
+ `annotate with @protocol: [{kind:'odata', path:''}, {kind:'hcql', path:'/hcql/'}];`
+ The OData `path` MUST match the service's current `@path` exactly, or the
+ OData URL moves and breaks every existing client.
+- Object-form entries only — a bare-string array collapses all adapters onto
+ one path and 404s OData (documented hazard in `admin-service-mcp.cds`).
+- **Kill switch:** delete `srv/hcql-enablement.cds` (removes 4 services) and
+ drop the `hcql` entry from AdminService's `@protocol` list; `cds build
+ --production`; redeploy.
+
+### 4.3 Approuter routing
+- Add `/hcql/*` (or per-service `/hcql/admin`, …) routes to the approuter
+ `xs-app.json` (root + `.deploy/` copy — keep both in sync), `authenticationType`
+ matching the OData routes (XSUAA), JWT-forwarded to `tutorials-srv`.
+- Verify against the documented Akamai constraints (POST bodies are fine;
+ `/hcql/*` is a POST-only JSON surface so bare PATCH/DELETE verb issues don't apply).
+
+### 4.4 Tests
+New `test/unit/hcql-enablement.test.js` (unit, in-memory), asserting the
+separation proven in the spike:
+- OData path unchanged: `GET //…$filter=… eq …` → 200; read-only
+ `PATCH` → 405; `POST /` with a CQN body → 405 (OData rejects CQN).
+- HCQL path works: `POST /hcql/` with a valid CQN `SELECT` → 200.
+- Robustness: `POST /hcql/` with malformed CQN → 400 and the server stays
+ up (no process exit).
+- Auth inherited: unauthenticated `POST /hcql/` → 401; wrong scope → 403.
+
+The previously-failing OData suites (author/analytics/admin/homepage/etc.) must
+stay green — they are the regression canary.
+
+### 4.5 Docs
+- Rewrite `docs/developers/reference/hcql-support.md`: distinct `/hcql/`
+ paths (not "same URL as OData"), authenticated-only 5-service table, requires
+ `@sap/cds >= 10.1.0`, and replace the process-exit hazard section with the
+ fixed-in-10.1.0 note (malformed CQN → 400).
+- Update the CLAUDE.md Top-Gotchas HCQL pointer to match (distinct path,
+ authenticated-only, 10.1.0, DoS-fixed).
+- Restore the VitePress sidebar entry (`#1003`) if it was removed.
+
+## 5. Acceptance criteria (from #2247, Option 1)
+- [x] Decision recorded: re-land, authenticated-only, on CAP 10.1.0.
+- [x] Root cause of the 218-test regression documented (this spec §2) and fixed
+ (explicit `@protocol` path isolation + CAP bump).
+- [x] `@hcql`/HCQL scoped to authenticated services only, never anonymous.
+- [x] Full unit suite green (9746 passed, 0 non-environmental failures). Hybrid suite: server boots clean under CAP 10.1.0 + HCQL + `@cap-js/mcp` + `@hono/node-server` 2.1.1; full hybrid run deferred to the maintainer on a `bind:setup`-provisioned environment (this fresh worktree lacks HANA/XSUAA/credstore bindings — see PR description).
+- [x] Docs updated to match reality.
+
+## 6. Risks & open questions
+- **CAP minor bump + dep-wide refresh blast radius** — primary risk (see §4.1).
+ Full suites + build gate it, phased for attribution.
+- **Decided (2026-09-11):** Phase B is within-major only; major bumps deferred.
+- **`@protocol` path exactness** — the OData `path` in each new `@protocol`
+ list must equal the current `@path`; a repo-wide check of each service's
+ `@path` is part of implementation.
+- **cds-dk vs cds version skew** — pin both to 10.1.x; cds-dk 10.1.1 is the
+ latest published, cds runtime 10.1.0.
+- **Hybrid/HANA validation** — the spike ran on SQLite in-memory; hybrid tests
+ against real HANA must confirm nothing HANA-specific breaks under 10.1.0.
diff --git a/docs/superpowers/specs/2026-09-11-assert-blocks-design.md b/docs/superpowers/specs/2026-09-11-assert-blocks-design.md
new file mode 100644
index 000000000..6ff02b2b2
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-assert-blocks-design.md
@@ -0,0 +1,210 @@
+# Assert Blocks → Verifiable Tutorials (parse + persist)
+
+**Issue:** [sap-tutorials/tutorials-ims#2245](https://github.com/sap-tutorials/tutorials-ims/issues/2245), item **#2**.
+**Date:** 2026-09-11
+**Scope of this slice:** *parse + persist only.* No assertion **runner**, no CI execution, no verify.sh emission. Those are follow-ups (#3 installable Skills, #4 self-healing) that consume the artifacts this slice produces.
+
+## 1. Goal
+
+Let a tutorial author declare **machine-checkable postconditions** on a step — "after this step, `cds compile` exits 0", "`GET /catalog/Books` returns 200", "`srv/cat-service.cds` exists". This slice parses that authoring syntax into structured data and persists it two ways:
+
+1. **Build artifacts** — public `asserts[]` in the Hugo step frontmatter + a server-only `.assert.json` sidecar in `.tutorial-cache/`.
+2. **HANA** — `AssertSpecs` rows loaded through the existing content-publish endpoint pattern.
+
+The output is the **dependency spine** for #3 (bundle asserts into a `verify.sh` inside an agent SKILL.md) and #4 (nightly self-healing runs the asserts). This slice deliberately stops before executing anything.
+
+## 2. Non-goals
+
+- Executing/grading assertions (runner). Out of scope; #3/#4.
+- Emitting `verify.sh` or SKILL.md bundles. #3.
+- Any UI surface (admin or learner-facing). Assertions are invisible in rendered content in this slice.
+- New feature flag. Parsing is inert (unused data) until a consumer ships; no runtime behavior changes, so no flag is required. (If a later consumer needs gating, it owns its own flag.)
+
+## 3. Precedent we mirror
+
+This feature is structurally identical to **CodeCheck** (`[CODECHECK_N]`) — an author-supplied, per-step, machine-checkable spec. We follow its end-to-end pipeline exactly rather than invent a new one:
+
+| Stage | CodeCheck (model) | AssertSpecs (this slice) |
+|---|---|---|
+| Parser | `scripts/parsers/codecheck.ts` (reads `rules.vr`) | **new** `scripts/parsers/assert.ts` (reads `rules.vr`) |
+| Step field | `TutorialStep.codeCheck` | **new** `TutorialStep.asserts?: AssertBlock[]` |
+| Attach + sidecar | `attachCodeCheckSpecs(steps, map)` at `fetch-tutorials.ts:1100-1112` | **new** `attachAssertSpecs(steps, map)`, mirror block |
+| Frontmatter emit | `render-frontmatter.ts` whitelist (~L127-141) | add `asserts` to whitelist |
+| Sidecar | `.codecheck.json` (`fetch-tutorials.ts` ~L1102) | **new** `.assert.json` |
+| Collect+POST | `scripts/lib/publish-codecheck.js` | **new** `scripts/lib/publish-asserts.js` |
+| Handler | `srv/lib/code-check-spec-publish.js` → `POST /content/code-check-specs` | **new** `srv/lib/assert-spec-publish.js` → `POST /content/assert-specs` |
+| Route mount | `srv/server.js` L942 | add mount + import |
+| Publish call | `publish-content.ts` L1348-1360 | mirror block |
+| Entity | `CodeCheckSpecs` (`db/schema.cds:853`) | **new** `AssertSpecs` (`db/tutorial-asserts.cds`) |
+| Journal | `db/persistence.cds` | add `AssertSpecs` entry |
+
+## 4. Authoring syntax
+
+**Location: the `rules.vr` companion file** — the same file that already holds `[VALIDATE_N]` and `[CODECHECK_N]` blocks, NOT the tutorial's step markdown. This is how every existing machine-checkable per-step spec is authored (`parseCodeCheckBlocks(rulesContent)` at `fetch-tutorials.ts:1100`). Because these markers never appear in the step body, there is **no body-stripping to do** — `scripts/parsers/v2.ts` is untouched. (An earlier draft of this spec wrongly claimed v2 strips `[CODECHECK_N]` from the body; it does not — codecheck lives in `rules.vr`.)
+
+`N` = the **step number** the assertion applies to (matching `[CODECHECK_N]`/`[VALIDATE_N]`, where `[CODECHECK_3]` targets step 3). A step may carry multiple assertions: repeat the `[ASSERT_N]` block with the same `N`. Each block is one assertion; its `assertIndex` is assigned by order of appearance within that step (0-based).
+
+Three types, each with its own required/optional subsections. `###Match` (a regex) is optional everywhere except `file`+`contains` where it is required.
+
+**cmd** — run a shell command, assert exit code:
+```
+[ASSERT_1]
+###Type
+cmd
+###Run
+cds compile srv
+###Expect
+exit 0
+###Match
+Deployed (optional: regex applied to stdout)
+```
+
+**http** — issue a request, assert status:
+```
+[ASSERT_2]
+###Type
+http
+###Method
+GET
+###Path
+/catalog/Books
+###Expect
+status 200
+###Match
+"value" (optional: regex applied to response body)
+```
+
+**file** — assert a file exists, or contains text:
+```
+[ASSERT_3]
+###Type
+file
+###Path
+srv/cat-service.cds
+###Expect
+exists (or: contains)
+###Match
+service CatalogService (REQUIRED when Expect is "contains"; ignored for "exists")
+```
+
+### Grammar of `###Expect`
+
+- `cmd`: `exit ` → `expectExit: number`.
+- `http`: `status ` → `expectStatus: number`.
+- `file`: `exists` → `expectContains: false`; `contains` → `expectContains: true` (+ required `match`).
+
+Unknown `###Type`, missing required subsection, or unparseable `###Expect` ⇒ the individual block is **dropped with a `console.warn`**, never thrown. This matches the tolerant posture of `codecheck.ts`/`rules.ts` (a malformed block must never abort a tutorial build or a content publish).
+
+## 5. Data model
+
+`scripts/parsers/types.ts`:
+
+```ts
+export type AssertType = 'cmd' | 'http' | 'file';
+
+export interface AssertBlock {
+ index: number; // assertIndex within the step, 0-based (order of appearance)
+ stepNumber: number; // N from [ASSERT_N]
+ type: AssertType;
+ // cmd
+ run?: string;
+ expectExit?: number;
+ // http
+ method?: string; // GET|POST|PUT|PATCH|DELETE|HEAD, upper-cased
+ path?: string;
+ expectStatus?: number;
+ // file
+ filePath?: string;
+ expectContains?: boolean; // false ⇒ "exists" check; true ⇒ "contains" check
+ // shared, optional
+ match?: string; // regex source string (stdout / body / file content)
+}
+
+export interface TutorialStep {
+ // ...existing...
+ asserts?: AssertBlock[];
+}
+```
+
+There is **no server-only secret** in an assert block (unlike codecheck's `referenceSolution`), so the public frontmatter shape equals the full shape. The `.assert.json` sidecar exists purely so server-side consumers (#3/#4) can read specs without scraping Hugo frontmatter — same rationale as the codecheck sidecar.
+
+## 6. Parser: `scripts/parsers/assert.ts`
+
+Reads the `rules.vr` companion content (never the step body), mirroring `parseCodeCheckBlocks`. Two exports:
+
+```ts
+// N in [ASSERT_N] is the step number; a step may have multiple blocks.
+export function parseAssertBlocks(content: string): Map
+// Attaches public asserts[] to matching steps in place; returns the flat
+// sidecar array (all blocks across all steps) for .assert.json.
+export function attachAssertSpecs(
+ steps: T[], specs: Map
+): AssertBlock[]
+```
+
+- Marker regex `^\[ASSERT_(\d+)\]\s*$`; sibling-marker regex closes an open block when the next `[VALIDATE_|CODECHECK_|ASSERT_\d+]` line appears (same flush pattern as `codecheck.ts:12-28`).
+- Per block: read `###Type`, then type-specific `###`-subsections via the existing `section(raw, name)` helper pattern; build+validate an `AssertBlock` per §4; assign `stepNumber = N`; append to `map.get(N)` (creating the array). `index` (assertIndex) = array position at append time.
+- Malformed block ⇒ `console.warn` + skip (never throw).
+- No fence-awareness needed — `rules.vr` is not rendered markdown, it is a directive file (matching `codecheck.ts`, which does not use a fence tracker).
+
+## 7. Wiring
+
+1. **`fetch-tutorials.ts`** — beside the codecheck block (`:1100-1112`), add: `const assertMap = parseAssertBlocks(rulesContent); if (assertMap.size) { const sidecar = attachAssertSpecs(steps, assertMap); if (sidecar.length) writeFileSync(join(CACHE_DIR, \`${t.slug.toLowerCase()}.assert.json\`), JSON.stringify({ slug: t.slug.toLowerCase(), specs: sidecar }, null, 2)); }`. `attachAssertSpecs` also sets `step.asserts` so frontmatter emit picks it up. No `compose.ts` change on the standard path; no `v2.ts` change.
+2. **`compose.ts` (preview parity, optional-but-included)** — in the `opts.rulesVr` preview block (`:222-228`), add `const assertMap = parseAssertBlocks(opts.rulesVr); if (assertMap.size) attachAssertSpecs(steps, assertMap);` so author-preview shows asserts too. No sidecar write here (preview has no cache), matching how codecheck preview differs from fetch.
+3. **`render-frontmatter.ts`** — add `if (s.asserts?.length) entry.asserts = s.asserts;` to the per-step whitelist (L128-139), emitted only when present.
+4. **`scripts/lib/publish-asserts.js`** — `collectAssertSpecs(cacheDir)` (reads `*.assert.json`, flattens to `{slug, ...spec}`, defensive skip on malformed) + `publishAssertSpecs(baseUrl, apiKey, specs)` (POST `/content/assert-specs`, returns `{upserted, skipped}`). Direct clone of `publish-codecheck.js`.
+5. **`srv/lib/assert-spec-publish.js`** — `assertSpecPublishHandler(req, res)`: validate `body.specs` (each needs `slug:string`, `stepNumber:number`, `type` ∈ enum, and type-specific required fields), then per spec resolve `Tutorials` by lower-cased slug (skip if absent), upsert `AssertSpecs` on `(tutorial_ID, stepNumber, assertIndex)` with **carry-forward** semantics (no DELETE). Clone of `code-check-spec-publish.js`.
+6. **`srv/server.js`** — import handler; `app.post('/content/assert-specs', express.json({limit:'5mb'}), contentAuthMiddleware, assertSpecPublishHandler)` beside L942.
+7. **`scripts/publish-content.ts`** — import + mirror the L1348-1360 collect/withRetry/publish block for asserts.
+
+## 8. CDS entity
+
+Defined **inline in `db/schema.cds`**, immediately after `CodeCheckSpecs` (~L870) — a faithful mirror of that entity (`: managed`, same declaration style). No separate `.cds` file, no new `using`.
+
+```cds
+// Author-supplied, machine-checkable step postconditions (issue #2245 item #2).
+// Populated by the publish-content pipeline (carry-forward upsert); consumed
+// by #3 (skill bundles) / #4 (self-healing). No secret columns — public == full.
+entity AssertSpecs : managed {
+ key tutorial : Association to Tutorials;
+ key stepNumber : Integer;
+ key assertIndex : Integer; // 0-based order within the step
+ assertType : String(8); // 'cmd' | 'http' | 'file'
+ run : LargeString; // cmd
+ expectExit : Integer; // cmd
+ httpMethod : String(8); // http
+ httpPath : LargeString; // http
+ expectStatus : Integer; // http
+ filePath : LargeString; // file
+ expectContains : Boolean; // file (false ⇒ exists check)
+ matchRegex : LargeString; // shared, nullable
+}
+```
+
+- Key `(tutorial, stepNumber, assertIndex)` — a step can hold multiple asserts, so `assertIndex` is part of the key (CodeCheckSpecs keys on just `(tutorial, stepNumber)`; called out because the upsert must key on all three).
+- **Not journaled** (absent from `persistence.cds`), exactly like `CodeCheckSpecs`. Assert rows are fully regenerable from `rules.vr` on the next publish, so a DROP+CREATE on schema change loses nothing. This is a deliberate mirror of the analog, not an oversight — do not add a `@cds.persistence.journal` entry.
+- Column named `assertType` (not `type`) and `httpMethod`/`httpPath` (not `method`/`path`) to avoid CDS keyword/reserved-name friction; `matchRegex` avoids overloading `match`. The JS `AssertBlock.type`/`method`/`path` map to these column names in the publish handler.
+
+## 9. Deploy / ops guardrails (from repo memory)
+
+- **srv-qa route drift:** `/content/assert-specs` is srv-only (a publish endpoint, like `/content/code-check-specs`). Add it to `ALLOWLIST_ONLY_ON_SRV` in `scripts/check-srv-qa-route-drift.ts` (~L73) and mirror the assertion in `test/unit/check-srv-qa-route-drift.test.ts`, or the drift guard fails CI.
+- **srv-qa cp-list:** `srv/lib/assert-spec-publish.js` is imported by `srv/server.js`. Confirm it (and any `./` imports it adds) is present in `.deploy/mta.yaml`'s `srv-qa` `cp` list exactly as `code-check-spec-publish.js` is — a missing transitive dep crashes srv-qa boot at MTA deploy.
+- **Route smoke:** add `{ path: '/content/assert-specs', method: 'POST' }` to `test/smoke/express-route-mutations.test.js` (~L22).
+- **Schema build:** `AssertSpecs` is not journaled (like `CodeCheckSpecs`), so it compiles to a plain `.hdbtable`. Run `cds build --production` after adding the entity to confirm the model compiles and the artifact generates; nothing to hand-edit.
+- **Seed-secrets doc:** append `/content/assert-specs` to the `CONTENT_API_KEY` description in `scripts/seed-secrets.cjs` L58 (cosmetic, keeps the endpoint list accurate).
+
+## 10. Testing (all offline — no HANA)
+
+Parse+persist is fully verifiable under `npm test` (in-memory SQLite + fixtures):
+
+1. **Parser** (`test/unit/assert-parser.test.js`) — a `rules.vr` string exercising all three types + `###Match` + two `[ASSERT_N]` blocks on the same `N`; assert the returned `Map` shape (multi-assert step keyed by ascending `assertIndex`), and that `attachAssertSpecs` sets `step.asserts` on matching steps and returns the flat sidecar array. Negatives: unknown `###Type`, missing `###Run`, missing required `###Match` on `contains`, stray marker with no subsections (all warn-and-skip, no throw).
+2. **Frontmatter emit** — compose a fixture tutorial, render frontmatter, assert `steps[].asserts` is present and correctly shaped, and absent when no asserts authored.
+3. **Sidecar collect** (`test/unit/assert-publish-cli.test.js`) — mirror `code-check-publish-cli.test.js`: temp dir with `*.assert.json`, assert `collectAssertSpecs` flattens/skips malformed.
+4. **Publish handler** (`test/unit/assert-spec-publish.test.js`) — mirror `code-check-spec-publish.test.js`: in-memory SQLite, 400 on invalid body/spec, upsert idempotency on re-post, skip on unknown slug, carry-forward (absent specs retained), multi-assert-per-step keying on `assertIndex`.
+
+**Definition of done:** all four test groups green under `npm test`; `cds build --production` succeeds with the new entity; route-drift + smoke guards updated and passing. No deploy required to prove this slice.
+
+## 11. Open questions resolved
+
+- *How AssertSpecs rows load into HANA* — traced: the content-publish pipeline (`publish-content.ts` → `POST /content/-specs` → carry-forward upsert handler). Mirrored exactly; the entity is not a dangling definition.
+- *Public vs server-only split* — none needed; asserts carry no secret. Sidecar retained only for server-side ergonomics.
diff --git a/docs/superpowers/specs/2026-09-11-kg-learning-path-design.md b/docs/superpowers/specs/2026-09-11-kg-learning-path-design.md
new file mode 100644
index 000000000..c0a276893
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-kg-learning-path-design.md
@@ -0,0 +1,221 @@
+# KG Learning-Path Reasoning — Design
+
+**Date:** 2026-09-11
+**Status:** Approved (brainstorming) — pending implementation plan
+**Issue:** TBD (KG learning-path / "what should I learn next")
+
+## Problem
+
+The knowledge graph already carries real prerequisite structure — `kg:requires`
+concept→concept edges, transitive closure `(^kg:requires)+`, `prerequisitesOf` /
+`whatToLearnNext` neighborhood arms, `pathBetween`, and `conceptsForUser`
+(learned-vs-partial skill progression). All of it is anonymous-readable behind the
+single master switch `KNOWLEDGE_GRAPH_ENABLED` (default OFF in dev).
+
+What is missing is the reasoning that turns those primitives into an **ordered,
+personalized curriculum**:
+
+1. **No topological ordering.** The path arms return *ranked candidate sets* and
+ transitive-closure *sets*, never a linearized "do A, then B, then C" ordering.
+2. **No personalized chain.** Nothing combines `conceptsForUser` (what a learner has
+ completed) with the `requires`-closure to produce "given where you are, the ordered
+ path to reach tutorial X."
+3. **No hardened, documented contract** distinct from the sidebar-oriented
+ `neighborhood` payload.
+
+## Goal
+
+Build the ordered + personalized reasoning **once**, as a pure testable core, and
+surface it:
+
+- **In-product first** — a "Your Learning Path" Vue island on developers.sap.com.
+- **Then as an MCP tool** — the identical core wrapped for AI agents (Joule / Claude /
+ external LLMs).
+
+Three path shapes, all fed into the same sequencer:
+
+| Shape | Goal input | Source edges |
+|---|---|---|
+| Path to a chosen tutorial | tutorial slug | `teaches` → `requires`-closure |
+| Path to complete a mission/group | mission/group slug | `partOf` members → union of `teaches` → `requires`-closure |
+| Next-best (no goal) | none | satisfied-prereq frontier over `requires` |
+
+## Privacy invariant
+
+User→tutorial edges are deliberately kept **out** of the graph (`srv/lib/kg/concepts-for-user.js`).
+Personalization therefore happens **at request time** from the authenticated user's
+completions and is **never persisted** into the KG. Anonymous requests get goal-only
+ordering (no subtraction) plus a "sign in to personalize" nudge.
+
+## Approach (selected: A — request-time reasoning module)
+
+Rejected alternatives:
+- **B — precomputed DAG + nightly ordering.** A chosen-tutorial path is inherently
+ goal-relative; a single global ordering can't express it, and the closures are small
+ enough that request-time is fine. Adds a job + table + migration not needed for v1.
+- **C — thin composition over existing endpoints.** Doesn't add topological ordering
+ (the actual gap) and duplicates logic across the in-product and MCP consumers.
+
+## Components
+
+### 1. Core reasoning module — `srv/lib/kg/learning-path.js` (net-new)
+
+Pure function, deterministic given a graph snapshot + learned set → unit-testable:
+
+```
+computeLearningPath({ goal, goalType, learnedConcepts, partialConcepts, graph })
+ → { steps, meta }
+```
+
+`graph` is a plain in-memory snapshot (no DB access here): `requires` adjacency,
+`teaches` links (tutorial↔concept), `partOf` membership, and per-tutorial ranks.
+
+Algorithm:
+
+1. **Resolve goal concept set G.**
+ - `tutorial` → concepts the tutorial `teaches`.
+ - `mission` / `group` → union of concepts taught by member tutorials (via `partOf`).
+ - `next-best` → G = ∅ (handled by step 6).
+2. **Backward `requires`-closure** from G → candidate concept set **C** (goal concepts
+ ∪ transitive prerequisites). Bounded: max depth ≈ 6, max nodes ≈ 200; on overflow set
+ `meta.truncated = true` and keep the nearest-to-goal frontier.
+3. **Subtract satisfied concepts:** `R = C − learnedConcepts`. Partial concepts stay in
+ `R` but are flagged `alreadyPartial`.
+4. **Topological sort** of `R` over the `requires` sub-DAG via **Kahn's algorithm**.
+ Cycle-breaking: if Kahn stalls (a cycle remains), drop the **lowest-confidence**
+ `requires` edge in the remaining cycle, continue, and increment `meta.cyclesBroken`.
+5. **Concept → tutorial mapping.** For each concept in topological order, pick the best
+ tutorial that `teaches` it — **highest `TutorialRank`, preferring not-yet-completed**.
+ Dedupe tutorials covering multiple concepts to their earliest position; drop tutorials
+ the learner already completed.
+6. **`next-best` variant.** Frontier = unlearned concepts whose `requires` prerequisites
+ are all in `learnedConcepts` → map to tutorials, rank, take top 3–5.
+
+Output:
+
+```
+steps: [{ order, tutorialSlug, teachesConcepts[], satisfiesPrereqFor[], alreadyPartial }]
+meta: { goalType, goal, totalSteps, cyclesBroken, truncated }
+```
+
+Defaults (overridable later, not in v1): cycle-break = drop lowest-confidence edge;
+best-tutorial = highest rank, prefer incomplete.
+
+### 2. Graph-assembly adapter — `srv/lib/kg/learning-path-graph.js` (net-new)
+
+The only component that touches HANA. Assembles the `graph` snapshot the core needs
+(`requires` edges, `teaches` links, `partOf`, ranks) from existing SPARQL procedures /
+CDS reads. Uses raw `db.run()` for any LOB-adjacent reads (avoids the CDS-QL LOB-locator
+expiry gotcha). Caches the **non-personalized** closure per `(goal, goalType)` with a
+short TTL (via existing `cds-caching`) since it is identical across users; personalization
+(the learned-set subtraction) is applied after the cache, per request.
+
+Keeping all dialect specifics here leaves the core dialect-agnostic and SQLite-testable
+in unit tests.
+
+### 3. Endpoint & auth — `KnowledgeGraphService`
+
+Add an unbound CDS function to the existing service (`@requires: 'any'`):
+
+```cds
+type LearningPathStep {
+ order : Integer;
+ tutorialSlug : String;
+ teachesConcepts : array of String;
+ satisfiesPrereqFor : array of String;
+ alreadyPartial : Boolean;
+}
+type LearningPathResult {
+ goalType : String;
+ goal : String;
+ totalSteps : Integer;
+ cyclesBroken : Integer;
+ truncated : Boolean;
+ personalized : Boolean;
+ steps : array of LearningPathStep;
+}
+function learningPath( goal: String, goalType: String ) returns LearningPathResult;
+```
+
+Handler:
+- Authenticated learner → derive learned/partial via existing `conceptsForUser(req.user)`
+ logic, subtract at request time, set `personalized: true`.
+- Anonymous → goal-only ordering, `personalized: false`.
+- `goalType ∈ { tutorial, mission, group, next-best }`; unknown → 400.
+- Gated by `KNOWLEDGE_GRAPH_ENABLED` master switch (existing `before('*')`) **and** the
+ new flag (§4). Fail-open: flag off → empty result, never a 500.
+
+### 4. Feature flag
+
+Register `KG_LEARNING_PATH_ENABLED` in `srv/lib/feature-flags/registry.js` as
+`kind: 'db'` (backed by `KnowledgeGraphSettings.learningPathEnabled` or an `ImsConfig`
+`flag.kg.learningPath` row), **DEV-only, default OFF, fail-open**. Must be registered so
+it surfaces in the admin Feature Flags UI (per the registry drift-test guard). Update
+`test/unit/feature-flags-registry.test.js` accordingly.
+
+### 5. In-product surface — Vue island (ships first)
+
+"Your Learning Path" island under `hugo-apps/`:
+- Tutorial object page (`u1-object-page.html`) → "Path to this tutorial" (`goalType=tutorial`).
+- Mission / group pages → "Complete this mission" ordering (`goalType=mission|group`).
+- Standalone next-best widget deferred (see YAGNI).
+
+Island behavior:
+- Login probe checks JSON + `body.authenticated` (not `r.ok`).
+- Authenticated → personalized path; anonymous → goal-only path + "sign in to personalize" nudge.
+- **Fail-open:** flag off / 503 / empty → island renders nothing; no layout break.
+- Built + hashed through the normal island pipeline (`postbuild:apps` / island-manifest);
+ add to `npm run setup` if it needs deps not already present at root.
+
+### 6. MCP surface — phase 2 (same core)
+
+Add `kg_learning_path(goal, goal_type)` to the KG MCP service
+(`srv/knowledge-graph-service-mcp.cds` + handler), wrapping the **identical** core module.
+Non-personalized in agent context (no `req.user`) unless a caller supplies a user id.
+Documented tool description ("Given a goal tutorial or mission, returns the ordered
+prerequisite chain of tutorials to complete").
+
+## Data flow
+
+```
+island / MCP tool
+ → learningPath(goal, goalType) [KnowledgeGraphService handler]
+ → learning-path-graph.js: assemble graph snapshot [HANA, cached non-personalized]
+ → conceptsForUser(req.user) [request-time, authenticated only]
+ → learning-path.js: computeLearningPath(...) [pure: closure → subtract → topo-sort → map]
+ ← { steps[], meta }
+```
+
+## Error handling
+
+- Master switch OFF → 503 (existing behavior).
+- New flag OFF → `{ steps: [], meta: { ... } }`, fail-open (island renders nothing).
+- Unknown `goalType` / missing `goal` → 400.
+- Goal slug not in graph → empty `steps`, `truncated: false`.
+- Cycle in `requires` → break lowest-confidence edge, `meta.cyclesBroken++`, never throw.
+- Closure overflow → `meta.truncated: true`, return nearest-to-goal frontier.
+- Graph-assembly failure → fail-open empty result + logged warning.
+
+## Testing
+
+- **Unit (pure core, dialect-agnostic):** fixture graphs covering a linear chain, a
+ diamond, a cycle (assert an edge is broken and ordering is still valid), learned-set
+ subtraction, all three `goalType`s, and the truncation bound.
+- **Handler:** shape assertions, flag-OFF → empty, anonymous vs authenticated
+ (`personalized` toggles, subtraction applied), unknown `goalType` → 400. Use the
+ `cds.test('serve', …, '--in-memory')` pattern (not `cds.deploy(cds.model)`); put any
+ test-only hooks on `globalThis` so a `cds.serve()`'d handler sees them.
+- No HANA-only reasoning in the core; graph assembly is mocked in unit tests.
+
+## YAGNI (out of scope for v1)
+
+- Difficulty weighting, time-based path optimization, editorial ordering overrides.
+- Standalone `/learn-next` next-best page (start with tutorial + mission on existing pages).
+- Precomputed/materialized ordering (Approach B).
+- Persisting any per-user path or user→tutorial edge.
+
+## Rollout
+
+DEV-only behind `KG_LEARNING_PATH_ENABLED` (default OFF) + `KNOWLEDGE_GRAPH_ENABLED`.
+Enable in DEV, verify all three shapes render and personalize, then decide on QA/PROD
+exposure and MCP-tool publication (phase 2) separately.
diff --git a/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md b/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md
new file mode 100644
index 000000000..b4b491937
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md
@@ -0,0 +1,293 @@
+# KTT — Kasimir Teaches TLAs — Design
+
+**Date:** 2026-09-11
+**Status:** Design approved; pending spec review before implementation planning
+**Route:** `/explore/ktt/` (new page under the "Explore" nav)
+
+## 1. Summary
+
+KTT ("Kasimir Teaches TLAs") is a mostly-fun, tongue-in-cheek page on
+developers.sap.com that teaches the enormous set of SAP three-letter
+acronyms through a Duolingo-style, **story-heavy** experience narrated by
+an animated Professor Kasimir (SAP's cat mascot). The core loop is
+**story-driven** (Kasimir teaches with dialogue and jokes) with
+**quiz drills mixed in** (multiple-choice / match / type). Lessons are
+grouped into a skill-tree of 3–4 units covering ~50–60 acronyms for v1.
+
+Kasimir is rendered as an original **stylized/flat SVG** recreation of
+SAP's Kasimir (grey tuxedo cat, white blaze/muzzle/chest/paws, big blue
+eyes, pink nose, dark collar + blue SAP-arrow tag), rigged and animated
+in code (CSS keyframes + SMIL). It is not a photoreal reproduction of the
+3D-rendered character; the art lives behind a component interface so it
+can later be swapped for a commissioned Lottie / rendered asset without
+touching lesson logic.
+
+## 2. Goals & non-goals
+
+**Goals (v1):**
+- A skill-tree map with 3–4 themed units, ~50–60 TLAs total.
+- Story-first lessons with drills interspersed; wrong answers re-queued.
+- An animated Kasimir present on every screen, driven by a small mood
+ state machine.
+- Progress that works logged-out (local) and follows a signed-in user
+ (synced to HANA), reusing the existing completion model so KTT lessons
+ appear in MyCompletions and unit rollups.
+- Content that is correct on the facts and fun in the voice.
+
+**Non-goals (v1):**
+- "Endless mode" over the full acronym firehose — **seam left in**, not built.
+- NGDS autosend for KTT completions — **explicitly excluded** for v1.
+- Per-acronym `STEP`-level completion rows — mastery stays inside the
+ lesson / local state for v1.
+- Photoreal 3D Kasimir, commissioned Lottie, or badges/leaderboard beyond
+ what MyCompletions/gameboard give for free.
+
+## 3. Architecture
+
+Chosen approach: **Hugo page + a single Vue 3 island** (the established
+platform pattern; ~17 islands already build into `hugo/static/js/`).
+
+```text
+/explore/ktt/ (Hugo page — SSR shell so the route is not an empty div)
+ └─ mounts one Vue 3 island: the KTT game
+ ├─ Landing / hero
+ ├─ Skill-tree map (units → lesson nodes)
+ ├─ Lesson (story beats + drills)
+ └─ Progress / results
+ island talks to CAP only for:
+ (1) progress sync when logged in
+ (2) live Kasimir "banter" line (AI, fail-open)
+
+Content: hugo/data/ktt_lessons.json (build-time AI-drafted, human-reviewed)
+Backend: srv/ktt-service.cds (@path:'/ktt') — thin
+Progress rows: reuse com.sap.developers.ims.TaskRecords (new taskType KTT_LESSON)
+```
+
+The island inherits Hugo nav, the Horizon theme, and the site build
+pipeline. The CAP surface stays deliberately small.
+
+## 4. Screens & UX flow
+
+Four screens inside the one island:
+
+1. **Landing / hero** — Kasimir (idle animation) introduces himself with a
+ tongue-in-cheek line; "Start / Continue" CTA; streak + XP badge.
+2. **Skill-tree map** — Duolingo-style path: 3–4 units, each a chain of
+ lesson nodes (locked → available → completed). Suggested units:
+ *Core Platform*, *Data & HANA*, *Security & Identity*,
+ *ABAP & Extensibility*. Kasimir reacts alongside progress.
+3. **Lesson** — the core loop:
+ - **Story beats** — Kasimir teaches a cluster of TLAs with dialogue +
+ jokes (scripted lines; occasional live-AI banter for freshness).
+ - **Drills** — interspersed: multiple-choice, match-the-expansion,
+ type-the-acronym. Wrong answers re-queued Duolingo-style; Kasimir
+ facepalms then encourages.
+ - **Lesson complete** — Kasimir celebrates (hop + confetti), XP awarded,
+ next node unlocks.
+4. **Progress / results** — streak, XP, acronyms mastered, per-unit
+ completion; feeds local + HANA sync.
+
+## 5. Content model & pipeline
+
+Lesson content is **one reviewed JSON file**, `hugo/data/ktt_lessons.json`,
+committed to git as the source of truth (regenerable, but never generated
+live at build).
+
+Shape (approximate):
+
+```jsonc
+{
+ "units": [
+ {
+ "id": "core-platform",
+ "title": "Core Platform",
+ "icon": "...",
+ "order": 1,
+ "lessons": [
+ {
+ "id": "core-platform-1",
+ "legacyId": 90001, // stable int, used for TaskRecords + rollup tokens
+ "title": "Meet the Platform",
+ "acronyms": [
+ { "tla": "BTP", "expansion": "Business Technology Platform",
+ "blurb": "…", "category": "platform" }
+ ],
+ "beats": [
+ { "type": "story", "kasimir": "…", "mood": "teaching" },
+ { "type": "drill", "kind": "mc", "tla": "BTP",
+ "prompt": "BTP stands for…", "answer": "Business Technology Platform",
+ "distractors": ["…", "…"],
+ "kasimirRight": "…", "kasimirWrong": "…" }
+ ]
+ }
+ ]
+ }
+ ]
+}
+```
+
+**Build-time generation** — `scripts/generate-ktt-lessons.ts`:
+- Seeds from a **hand-curated acronym → expansion + category list**. The
+ facts (TLA → expansion) are **generated by Claude but grounded against
+ `sap-devs-server` / `cds-mcp` / official docs — not invented from
+ training data** — and are reviewed by a human before commit
+ (per the project rule: never rely on training data for SAP facts).
+- AI drafts **only** Kasimir's personality lines and plausible-but-wrong
+ distractors; these land in the JSON for human review.
+- Wired as an **explicit `build:all` step** (not a `pre/post` hook — those
+ are silenced by the global `ignore-scripts=true`).
+
+**Endless-mode seam:** the lesson engine reads beats from a **pluggable
+source** (data file now; a CAP-backed acronym feed later), so a future
+"endless mode" over the full acronym set drops in without a rewrite.
+
+## 6. Animated Kasimir — `KasimirStage` component
+
+A self-contained Vue component wrapping **one rigged inline SVG** of
+stylized Kasimir. Parts (ears, eyelids, pupils, mouth, whiskers, tail,
+front paws, head) are separate SVG groups so they animate independently.
+
+- **State machine** prop: `idle | teaching | thinking | correct | wrong |
+ celebrate`, driven with CSS keyframes + SMIL:
+ - idle — breathing + tail flick + periodic blink
+ - teaching — gentle gesture, eyes toward content
+ - thinking — ear twitch, eyes up
+ - correct — ears perk, eye sparkle
+ - wrong — facepalm paw, flattened ears
+ - celebrate — hop + confetti burst
+- **Accessibility:** honors `prefers-reduced-motion` (freezes to a calm
+ pose); the SVG is decorative (`aria-hidden`), while Kasimir's teaching
+ text lives in real DOM for screen readers.
+- **Swappable art:** the SVG sits behind the component interface, so a
+ future commissioned Lottie / rendered Kasimir replaces the innards
+ without touching lesson logic.
+- **Themed** to Horizon tokens so it fits `sap_horizon`.
+
+## 7. Backend — reuse the completion model
+
+The completable unit is a **KTT lesson**. It reuses the existing
+completion machinery rather than a new progress entity.
+
+### 7.1 Progress rows — `TaskRecords`
+
+- Each completed lesson writes a `com.sap.developers.ims.TaskRecords` row
+ (`db/schema.cds:188`) with `taskType:'KTT_LESSON'`, `status`, `progress`,
+ `completionDate`.
+- Upserted on the de-facto tuple **`(user_ID, taskLegacyId, taskType)`**
+ (same convention as `puzzle-service.js` / `petoberfest-upload.js`), so
+ re-completion updates rather than duplicates.
+
+### 7.2 Catalog entity — `KttLessons`
+
+- One lightweight entity (id, `legacyId`, slug, unit, title), seeded from
+ `ktt_lessons.json`, mirroring how `Tutorials` / `Puzzles` /
+ `Petoberfests` exist so a completion row can join to its metadata for
+ MyCompletions.
+- New persisted entity → add a `db/persistence.cds` journal entry;
+ run `cds deploy --to sqlite::memory:` before committing the model.
+
+### 7.3 Unit rollups (free)
+
+- Each unit is modeled as a mission-style parent via `CompletionPathItems`
+ whose children are `KTT_LESSON:` tokens.
+- `srv/lib/completion-rollup.js` already matches mission parents by
+ `{taskType, taskLegacyId}` (`:159`) and upserts the parent GROUP/MISSION
+ row — so "unit mastered" appears with **no rollup-code changes**.
+
+### 7.4 `KttService` (`srv/ktt-service.cds`, `@path:'/ktt'`)
+
+- `completeLesson` (XSUAA-scoped write) — writes the `TaskRecords` row and
+ calls `rollUpParentsForCompletion({ task:{ taskType:'KTT_LESSON', … } })`
+ (same call shape as `puzzle-service.js:223`).
+- `syncProgress` — merges local ↔ HANA on login: union of mastered TLAs,
+ max of XP/streak; idempotent via the upsert tuple.
+- `banter(context)` — `@cap-js/ai` action returning a short in-character
+ line. `AICore-mocked` locally, `AICore-btp` in hybrid/prod. **Fail-open:**
+ any error/timeout returns a scripted line from the data file; rate-limited
+ and length-capped; runs edge-side, never inside a progress transaction.
+
+### 7.5 Progress: local-first + hybrid
+
+- Logged-out: everything lives in `localStorage`; the game is fully playable.
+- On login: local completions replay through `completeLesson` as
+ `TaskRecords` rows; the upsert tuple prevents double-counting.
+
+### 7.6 Concrete edit sites
+
+- `db/schema.cds:195` — add `KTT_LESSON` to `TaskRecords.taskType`
+ (NVARCHAR column; **no `.hdbmigrationtable` ALTER needed** — see comment
+ at `:191`).
+- `db/schema.cds:19` — add `KTT_LESSON` to `type TaskType` so KTT lessons
+ pass the `@assert.range` at `:418` when used as `CompletionPathItems`
+ children.
+- `srv/lib/user-progress.js` (~`:175`, `:183`, `:213`) — add `KTT_LESSON`
+ to the MyCompletions `taskType IN (…)` list, an ID bucket, a metadata
+ SELECT against `KttLessons`, and a `kind:'ktt'` branch.
+- New `KttService` handlers per 7.4.
+- **Not changed for v1:** `srv/lib/ngds-client.js` (NGDS autosend excluded),
+ `featured-resolve.js`, `admin-service.js`, exports — revisit only if KTT
+ later joins those flows.
+
+### 7.7 Feature flag
+
+- KTT gated by an `ImsConfig` DB flag registered in
+ `feature-flags/registry.js` (`kind:'db'`) so it surfaces in the admin
+ Feature Flags UI and toggles without redeploy. **Default OFF** until
+ DEV-verified. Flag off ⇒ `/explore/ktt/` fails closed (coming-soon / 404
+ per convention).
+
+#### Enabling on DEV (runbook)
+
+The flag is a **runtime DB change on the deployed backend** — no redeploy, no
+code change. Two equivalent paths:
+
+1. **Admin UI (preferred):** open `/admin-ui/#featureFlags` on the DEV
+ approuter, find **"KTT — Kasimir Teaches TLAs"** (category *Content*), and
+ toggle it on. Persists to `ImsConfig` key `flag.ktt.enabled`.
+2. **Action call:** invoke `featureFlagUpsert('KTT_ENABLED', 'flag.ktt.enabled')`
+ against `AdminService` with `value: true` (the exact `howToChange` recorded
+ in the registry entry).
+
+Verify: `GET /ktt/Lessons?$top=1` returns `200` (not `503`), and
+`/explore/ktt/` renders the landing screen (Start button) instead of the
+coming-soon section. The island probes the flag via `isKttEnabled()` on mount,
+so a hard refresh is enough — no rebuild. Flip back to `false` to fail closed
+again.
+
+
+## 8. Error handling & resilience
+
+- **AI banter non-blocking** — timeout + try/catch; falls back to a scripted
+ line. Lesson loop never stalls on the model.
+- **Progress sync best-effort** — a failed `TaskRecords` write shows a quiet
+ toast, never loses local state, retries on next completion; never throws
+ into the rollup transaction.
+- **Data file validated at build** — `generate-ktt-lessons.ts` + a unit test
+ assert: every drill has a correct answer and ≥2 distractors, every acronym
+ has an expansion, `legacyId`s are unique. Malformed lessons fail the build.
+- **Reduced-motion** honored by `KasimirStage`.
+
+## 9. Testing
+
+- **Unit (in-memory SQLite):** `completeLesson` writes a `TaskRecords` row on
+ the `(user, legacyId, KTT_LESSON)` tuple; re-completion upserts (no dupes);
+ unit-parent rollup produces the mission/group row via existing
+ `completion-rollup.js`; `getMyCompletions` returns the `kind:'ktt'` entry.
+ Guard the model with `cds deploy --to sqlite::memory:` pre-commit.
+- **Lesson-engine unit tests:** drill scoring, wrong-answer re-queue,
+ XP/streak math, local↔HANA merge (union mastered, max XP/streak).
+- **Island component tests:** `KasimirStage` state transitions; run via
+ `--project unit` from repo root (hugo-apps `.vue` gotcha).
+- **AI banter:** mocked (`AICore-mocked`) locally; test the fail-open path
+ explicitly.
+- **e2e (post-deploy, self-skipping):** committed Playwright spec — load
+ `/explore/ktt/`, start a lesson, answer a drill, see Kasimir react.
+- **srv-qa cp-list audit** if any KTT helper lands under `srv/lib/` reachable
+ from `content-store.js`.
+
+## 10. Open items for implementation planning
+
+- Final unit list and the ~50–60 acronym set (drafted grounded, human-reviewed).
+- Exact XP/streak formula and re-queue policy.
+- Whether the landing SSR shell needs any crawler-facing acronym content.
+- Confirm `/explore/ktt/` nav placement and label wording under "Explore".
diff --git a/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md b/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md
new file mode 100644
index 000000000..783188d6a
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md
@@ -0,0 +1,199 @@
+# Design: Tutorials as installable agent Skills (`GET /content/tutorials/:slug/skill`)
+
+**Issue:** [#2245](https://github.com/sap-tutorials/tutorials-ims/issues/2245) item 3
+**Depends on:** #2256 (signed provenance envelope), #2259 (assert blocks → parse + persist)
+**Date:** 2026-09-11
+**Status:** Approved design → implementation planning
+
+## Goal
+
+Turn a published tutorial into a capability an AI agent can *install and perform*, not just
+read. `GET /content/tutorials/:slug/skill` returns a zip archive that unpacks to an
+installable Skill directory: a `SKILL.md` procedure, a runnable `verify.sh` generated from
+the tutorial's assert blocks, and a provenance/freshness stamp. This is item 3 of the
+"executable, self-verifying, self-healing tutorials" spine — it composes the outputs of
+items 1 (provenance) and 2 (assert blocks) that already shipped.
+
+## Non-goals
+
+- No new persistence. The endpoint is a pure read/compose over `getTutorialSource`,
+ `AssertSpecs`, and `loadProvenanceInputs`.
+- No actual execution of `verify.sh` server-side (that is item 4, self-healing).
+- No per-code-sample separate files in v1 — code fences are carried inline in `SKILL.md`
+ (YAGNI; revisit if an agent consumer needs standalone sample files).
+- No QA-channel (`srv-qa`) support — published-content trust surface only, mirroring the
+ provenance endpoint's rationale.
+
+## Route & registration
+
+- **Path:** `GET /content/tutorials/:slug/skill` — sibling of the existing
+ `/content/tutorials/:slug/provenance` (#2256).
+- **Registration:** plain Express route inside the `cds.on('bootstrap', (app) => {...})`
+ block in `srv/server.js`, registered **before** the `/content/tutorials/*slug` wildcard
+ (`serveHandler`) so the wildcard does not swallow it — same ordering discipline as
+ `/provenance`.
+- **Auth:** anonymous public read (no `contentAuthMiddleware`), consistent with tutorials
+ and `/provenance`.
+- **Drift guard:** add `'GET /content/tutorials/:slug/skill'` to `ALLOWLIST_ONLY_ON_SRV` in
+ `scripts/check-srv-qa-route-drift.ts` with a rationale mirroring the provenance entry
+ (published-content-only, no srv-qa reader).
+
+## Feature flag
+
+New DB feature flag in `srv/lib/feature-flags/registry.js`, following the
+`PROVENANCE_ENVELOPE_ENABLED` shape exactly:
+
+```js
+{
+ key: 'SKILL_BUNDLE_ENABLED', label: 'Installable Skill bundle endpoint', category: 'Content',
+ kind: 'db', imsConfigKey: 'flag.skill.bundle',
+ valueType: 'boolean', default: false, status: 'dev-only',
+ description: 'When true, serves an installable agent-Skill zip at '
+ + '/content/tutorials/:slug/skill (SKILL.md procedure + verify.sh generated from assert '
+ + 'blocks + provenance/freshness stamp). Public, anonymous, read-only over PUBLISHED '
+ + 'tutorials. DB-driven config (ImsConfig key flag.skill.bundle); no env var. Default OFF (#2245).',
+ howToChange: featureFlagUpsert('SKILL_BUNDLE_ENABLED', 'flag.skill.bundle'),
+}
+```
+
+Flag OFF → **404** (fail-closed on the feature; the endpoint should not advertise its
+existence). Reading the flag reuses whatever helper `provenanceHandler` uses to read
+`PROVENANCE_ENVELOPE_ENABLED` (confirm during implementation and mirror it).
+
+## Handler module: `srv/lib/skill-bundle.js`
+
+New module exporting a factory `createSkillBundleHandler(deps)` (for testability) and a
+default `skillBundleHandler(req, res)` bound to production deps, mirroring the
+provenance-handlers factory/default pattern.
+
+Injectable deps (default to the real implementations):
+- `getTutorialSource(slug)` — from `content-store.js` (raw markdown).
+- `loadAssertSpecs(slug)` — new small helper (below).
+- `loadProvenanceInputs(slug)` + `buildEnvelope(...)` — from provenance libs.
+- `isFlagEnabled('SKILL_BUNDLE_ENABLED')`.
+
+### Request flow
+
+1. **Flag check** → 404 if disabled.
+2. **Canonicalize slug** (lowercase; 301 redirect on non-canonical) — reuse the existing
+ canonicalization helper used by `serveHandler`/`provenanceHandler`.
+3. `getTutorialSource(slug)` → `{ markdown, sourceHash, contentHash }`. If `markdown` is
+ null/absent (legacy or unknown slug) → **404**.
+4. `loadAssertSpecs(slug)` → ordered `AssertSpec[]` (may be empty).
+5. `loadProvenanceInputs(slug)`; if the provenance flag is on and inputs exist, `buildEnvelope`
+ to obtain `{ jws, claims }`. **Fail-open**: any error or missing data → stamp degrades to
+ `confidence: 'unknown'`, no `jws`. The Skill bundle never fails because provenance is off.
+6. Compose `SKILL.md` and `verify.sh` strings (pure functions, below).
+7. Stream a zip via `archiver` (already a declared dependency, `archiver@8.0.0`):
+ - `Content-Type: application/zip`
+ - `Content-Disposition: attachment; filename="-skill.zip"`
+ - Entries: `/SKILL.md`, `/verify.sh` (mode `0o755`).
+ - On `archiver` `error` event → 500 (if headers not yet sent) + `console.error`.
+
+### `loadAssertSpecs(slug)` helper
+
+Since #2259 added no read projection, read the entity directly:
+
+1. `SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })` → if none, return `[]`.
+2. `SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber','assertIndex')`.
+3. Map DB columns back to the logical assert shape (reverse of the #2259 remapping):
+ `assertType→type`, `httpMethod→method`, `httpPath→path`, `matchRegex→match`; keep
+ `run`, `expectExit`, `expectStatus`, `filePath`, `expectContains`, `stepNumber`,
+ `assertIndex`.
+
+Entity access via `cds.entities('com.sap.developers.ims')`. Fail-open: on error, log and
+return `[]` (bundle still ships, verify.sh has no checks).
+
+## Composition (pure functions)
+
+### `buildSkillMd({ slug, title, description, markdown, asserts, stamp })`
+
+Returns a `SKILL.md` string:
+
+- **YAML frontmatter:** `name: `, `description:` (tutorial title + short description,
+ single-line, YAML-escaped). Frontmatter matches the agent-skill convention (name +
+ description are what agents index for discovery).
+- **Procedure body:** the tutorial's step content derived from `markdown`. Code fences are
+ carried through inline as fenced blocks. Steps come from the parsed markdown structure
+ (H2/H3 per parser v2 — reuse the existing step-parsing helper rather than re-parsing by
+ hand; confirm the reusable entry point during implementation).
+- **`## Verifying this Skill`** section: instructs the agent to run `bash verify.sh`
+ (with `BASE_URL` when http asserts are present), and states how many automated checks
+ are bundled.
+- **`## Provenance & freshness`** section: `confidence`, `last-verified` date
+ (`stamp.lastScanned`), `source-commit` (`stamp.sourceCommit`), and the JWS token when
+ available (fenced), plus the JWKS URL for verification.
+
+### `buildVerifyScript(asserts)`
+
+Returns a bash script string:
+
+- Header: `#!/usr/bin/env bash` + `set -euo pipefail`, a `fail()` helper, a pass/fail
+ counter, and (only when any http assert exists) `BASE_URL="${BASE_URL:-http://localhost:4004}"`.
+- One check block per assert, in `(stepNumber, assertIndex)` order, each echoing a labeled
+ check line:
+ - **cmd:** run `run`; capture output+exit; assert exit `== expectExit`; if `match`,
+ `grep -Eq -- ""` on captured output.
+ - **http:** `code=$(curl -s -o /tmp/... -w '%{http_code}' -X "${BASE_URL}")`;
+ assert `code == expectStatus`; if `match`, `grep -Eq` on the response body.
+ - **file:** `test -f ""`; if `expectContains`, `grep -Eq -- "" ""`.
+- All interpolated values (`run`, `path`, `filePath`, `match`) are **shell-quoted** via a
+ single-quote-escaping helper to prevent breakage/injection from spec content.
+- **Empty asserts:** script echoes `"No automated checks defined for this tutorial."` and
+ `exit 0`.
+- Footer: print summary; `exit 1` if any check failed.
+
+## Error handling
+
+| Condition | Response |
+|---|---|
+| `SKILL_BUNDLE_ENABLED` off | 404 |
+| Non-canonical slug | 301 → canonical `/skill` URL |
+| Unknown / legacy slug (no source markdown) | 404 |
+| Provenance off / no inputs / build error | 200, stamp `confidence: 'unknown'`, no JWS (fail-open) |
+| AssertSpecs read error | 200, verify.sh with no checks (fail-open) |
+| `archiver` stream error | 500 + logged (if headers unsent) |
+
+## Testing
+
+Follow existing patterns (`test/lib/provenance-endpoint.test.js`, `test/unit/provenance-*.test.js`,
+`test/unit/assert-*.test.js`).
+
+- **`test/unit/skill-bundle-compose.test.js`** — pure composition:
+ - `buildVerifyScript`: one representative test per assert type (cmd exit + match, http
+ status + method, file exists, file contains), multi-assert ordering, empty → `exit 0`
+ notice, shell-quoting of a value containing quotes/`$`.
+ - `buildSkillMd`: frontmatter has `name` + `description`; provenance section reflects
+ `high`/`unknown` stamps; JWS included only when present.
+- **`test/lib/skill-bundle-endpoint.test.js`** — handler via injected deps:
+ - flag off → 404; unknown slug → 404; happy path → 200, `application/zip`,
+ `Content-Disposition` filename; unzip (via `jszip`, already installed) and assert the
+ two entries exist and `verify.sh` content matches the specs; provenance-off path still
+ 200 with degraded stamp.
+- **Route/registration:** extend `test/smoke/express-route-mutations.test.js` and the
+ drift-guard test (`test/unit/check-srv-qa-route-drift.test.ts`) for the new allowlist entry.
+
+## Files touched
+
+| File | Change |
+|---|---|
+| `srv/lib/skill-bundle.js` | **new** — handler factory + `buildSkillMd` + `buildVerifyScript` + `loadAssertSpecs` |
+| `srv/server.js` | register `GET /content/tutorials/:slug/skill` before the `*slug` wildcard; import handler |
+| `srv/lib/feature-flags/registry.js` | add `SKILL_BUNDLE_ENABLED` entry |
+| `scripts/check-srv-qa-route-drift.ts` | add allowlist entry + rationale |
+| `test/unit/skill-bundle-compose.test.js` | **new** |
+| `test/lib/skill-bundle-endpoint.test.js` | **new** |
+| `test/smoke/express-route-mutations.test.js` | extend for new route |
+| `test/unit/check-srv-qa-route-drift.test.ts` | extend for new allowlist entry |
+
+## Open items to confirm during implementation
+
+1. The exact reusable slug-canonicalization helper and flag-read helper used by
+ `provenanceHandler` (mirror, don't reinvent).
+2. The reusable markdown→steps parsing entry point for `buildSkillMd` (parser v2 uses H3
+ steps); if none is cleanly importable server-side, fall back to emitting the raw markdown
+ body verbatim under the procedure section (still valid; less structured).
+3. Whether `srv-qa`'s `content-store.js` cp-list is affected — `skill-bundle.js` is a new
+ `srv/lib` module but is **not** a transitive `./` import of `content-store.js`; it imports
+ *from* content-store. Confirm it is added to the `srv` module only (not srv-qa) and that
+ the drift guard covers the route.
diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue
index 512893ee0..2e01b36ab 100644
--- a/hugo-apps/src/app-space/AppSpace.vue
+++ b/hugo-apps/src/app-space/AppSpace.vue
@@ -306,9 +306,10 @@ const emptyStateMessage = computed(() => {
/>
{{ eventName }}
Developer Garage — App Space
-
- {{ eventDescription }}
-
+
+
@@ -594,6 +595,11 @@ const emptyStateMessage = computed(() => {
opacity: 0.7;
margin: 0;
}
+/* Markdown wraps text in its own block tags — collapse their outer margins so a
+ single-paragraph description reads like the previous plain-text line (#2296).
+ :deep() needed: v-html children carry no scoped-style attribute. */
+.hero-desc :deep(:first-child) { margin-top: 0; }
+.hero-desc :deep(:last-child) { margin-bottom: 0; }
.hero-stats {
display: flex;
diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.markdown.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.markdown.test.ts
new file mode 100644
index 000000000..8db4eaa30
--- /dev/null
+++ b/hugo-apps/src/app-space/__tests__/AppSpace.markdown.test.ts
@@ -0,0 +1,49 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+
+vi.mock('../useRealtimeProgress', async () => {
+ const { ref } = await import('vue')
+ return { useRealtimeProgress: () => ({ lastCompletion: ref(null), connected: ref(false) }) }
+})
+
+import AppSpace from '../AppSpace.vue'
+
+// eventDescription arrives from the server already rendered to safe HTML
+// (srv/lib/markdown.js, html:false). The client drops it in via v-html — this
+// test guards that wiring (a regression to {{ }} would escape the tags).
+function stubFetch(eventDescription: string) {
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
+ const url = String(input)
+ if (url.includes('getAppSpaceProgress')) {
+ return {
+ ok: true, status: 200, json: async () => ({
+ eventId: 10000004, eventName: 'Joule Agent Lab', eventType: 'OTHER', type: 'COMPLEX',
+ eventDescription,
+ paths: [{ id: 1, title: 'Track A', description: '', items: [] }]
+ })
+ }
+ }
+ return { ok: false, status: 404, json: async () => ({}) }
+ }) as unknown as typeof fetch)
+}
+
+describe('AppSpace event description Markdown → HTML (#2296)', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ window.history.replaceState({}, '', '/app-space/?eventId=10000004')
+ })
+
+ it('renders server-supplied description HTML via v-html (not escaped text)', async () => {
+ stubFetch('
via window.matchMedia so we don't double-fetch + double-emit
telemetry. Same QA/preview gate as the desktop mount. */}}
{{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }}
+ {{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }}