Skip to content

tutorials-ims Gotchas ​

Overflow for project-specific gotchas that used to live in CLAUDE.md. The top ~10 that repeatedly bite live in CLAUDE.md itself; everything else is here.

Cross-references:

Build pipeline ​

  • POC tutorial list is dynamic β€” Tutorials are discovered from sap-tutorials GitHub org via discoverAllTutorials() in scripts/parsers/github.ts. EXCLUDED_REPOS (just tutorials-ims) skipped. Private repos excluded by default; INCLUDED_PRIVATE_REPOS is allowlist (currently meta-tutorials). -Contribution private repos gated by INCLUDE_CONTRIBUTION_REPOS / ONLY_CONTRIBUTION_REPOS. Discovery cached in .tutorial-cache/discovery-map.json. npm run discover-repos lists without fetching.
  • Validation quiz data from -Contribution repos β€” fetchRulesVr() in scripts/parsers/github.ts fetches rules.vr from private -Contribution repos. Needs GITHUB_TOKEN. Cached at .tutorial-cache/<slug>.rules.vr. Parsed by scripts/parsers/rules.ts, injected into Hugo frontmatter steps.
  • GITHUB_TOKEN env var β€” scripts/parsers/github.ts optionally uses it to avoid GitHub API rate limits.
  • CAP_BASE_URL env var β€” Used by scripts/parsers/cap.ts and migration scripts. Defaults to http://localhost:4004.
  • Node.js >= 20 required β€” Build scripts use native fetch (no polyfill).
  • Slug fields β€” Missions.slug and CompletionPaths.slug must be populated for the build pipeline to generate mission/group pages. Run node scripts/migrate-reference-data.js populate-slugs after data import.

Directory layout ​

  • app/ vs hugo-apps/ β€” app/ = standalone UI apps with their own builds (admin-shell, admin, analytics-explorer, scanner, display-app), each deploys by copying dist//webapp/ into approuter/static/<route>/. hugo-apps/ = single Vite project compiling ~17 Vue 3 page-level islands into hugo/static/js/ β€” loaded by Hugo templates as <script> tags, not deployed as routes. hugo-apps/src/{shared,composables}/ are utility modules, not islands. See mta.yaml.
  • Vite ↔ Hugo js.Build output collisions β€” Vite entries write to hugo/static/js/<name>.js. Hugo's resources.Get "js/<X>.ts" | js.Build writes to hugo/public/js/<X>.js after Hugo copies static/ β†’ public/, silently clobbering Vite if names collide. postbuild:apps runs tsx scripts/check-build-collisions.ts β€” fix by renaming.
  • /admin/ is OData only β€” AdminService OData lives at /admin/. The admin shell UI is served at /admin-ui/ to avoid path collisions.
  • Hugo vs VitePress β€” Project migrated from VitePress to Hugo. site/.vitepress/ still exists (with built dist/) but is legacy. Active frontend work targets hugo/.
  • hugo/content/tutorials/ is entirely generated β€” Never edit these files directly; they're overwritten by npm run fetch-tutorials. Edit scripts/parsers/ or source tutorials in the sap-tutorials GitHub org.
  • Cache clearing β€” .tutorial-cache/ caches raw markdown, GitHub metadata, and CAP catalog data. Delete it to force a full re-fetch. No incremental invalidation.

Content persistence & publish ​

  • Tutorials are DB-only β€” HTML served exclusively from HANA BLOBs. No static file fallback. If nothing published, /tutorials/* returns 404.
  • Content garbage collection β€” Daily cron (03:00) prunes SUPERSEDED/ROLLED_BACK versions older than 7 days, keeping the 3 most recent for rollback. Never touches ACTIVE/PUBLISHING.
  • publish-content.ts flags β€” Default mode is now correctness-equivalent to --force: server's commit carries forward unchanged slugs. --force is a perf/CI-convenience flag (skips /content/hashes round-trip). CLI auto-verifies after publish; exits 2 on hash mismatch. --verify-only / --heal / --dry-run. --force/--heal/--verify-only mutually exclusive.
  • HANA LOB locator expiry β€” CDS QL returns HANA BLOBs as Readable streams with locators that expire before consumption when mixed with non-BLOB columns. srv/lib/content-store.js uses raw SQL (db.run()) for BLOB retrieval on HANA, CDS QL for SQLite tests. Never SELECT a BLOB alongside metadata in a single CDS QL query on HANA.
  • Tutorial embeddings live in TutorialEmbedding and are HANA-only at query time β€” SQLite test path uses JS-side cosine. Never SELECT the embedding BLOB alongside metadata in a single CDS QL query on HANA; use db.run() raw SQL in srv/lib/embedding-query.js.
  • Tutorial/Mission/Group slugs are unique (case-insensitive) β€” @assert.unique.slug on Tutorials, Missions, Groups. New write paths MUST upsert on slug, not blind-INSERT. Canonical pattern at srv/lib/content-publish-session.js:285. Hybrid test test/hybrid/duplicate-slugs.test.js guards. Repair: npx cds bind --exec -- node scripts/merge-duplicate-slugs.cjs --commit.
  • TutorialMeta is a logical singleton (one row per tutorial) β€” @assert.unique.tutorial on TutorialMeta. Auto-init at srv/lib/content-publish-session.js:349 checks existing before INSERT. Hybrid test test/hybrid/duplicate-tutorial-meta.test.js guards. Repair: npx cds bind --exec -- node scripts/dedupe-tutorial-meta.cjs --commit.
  • MyTutorialsView.repositoryName sources from RepoCatalog.repo, NOT TutorialMeta.repository (#1063) β€” The TutorialMeta.repository β†’ TutorialRepositories.name chain is de-facto empty in DEV (0/2930 rows have repository_ID set; publish flow never populates it, only the legacy backfill script does), and TutorialRepositories is missing rows for the flagship Tutorials repo entirely. RepoCatalog is populated on every content publish by srv/lib/repo-catalog.js and covers 100% of live tutorials β€” that's what MyTutorialsView + MyMonitoredTutorialsView now left-join to. TutorialMeta.repository FK is retained for other consumers (scripts/soft-delete-sandbox-tutorials.cjs pass-1) but is no longer the source of truth for the Sage-facing view. If you're adding a new repositoryName-shaped field on any view, join RepoCatalog on slug; do not chase the TutorialMeta chain.

QA channel ​

  • QA channel content β€” /tutorials-qa/* is gated by XSUAA scope Tutorial.Author. Content sourced only from *-Contribution repos via ONLY_CONTRIBUTION_REPOS=true. Lives in tutorials-db-qa HDI; never queries prod tables.
  • .tutorial-cache-qa/ vs .tutorial-cache/ β€” separate caches per channel. fetch-tutorials writes a .channel marker; dev warns if content channel doesn't match.
  • CONTENT_API_KEY_QA env var β€” required for POST /content/publish and /content/rollback on QA srv.
  • hugo.qa.toml β€” sibling Hugo config for QA. Strips Joule FAB, rating, completion buttons, progress UI when site.Params.qa = true.
  • QA bootstrap runbook β€” docs/developers/operations/qa-channel-bootstrap.md.

Rebuild workflow & admin writes ​

  • rebuild-content.yml mode auto-infer β€” gh workflow run rebuild-content.yml -f slug=X auto-infers mode=slug-targeted when inputs.mode is default full AND a slug input is set. Don't pass -f mode=slug-targeted. Only workflow_dispatch auto-infers; repository_dispatch (admin auto-trigger) uses srv/lib/_classify-rebuild-mode.js. Wall-clock: catalog-only ~5min, slug-targeted ~2min, full ~10min. Runbook: rebuild-content-workflow.md.
  • GITHUB_DISPATCH_TOKEN env var β€” Read by srv/lib/rebuild-trigger.js; admin saves debounce-dispatch rebuild-content.yml after 60s. Sourced from DISPATCH_TOKEN GitHub Actions secret (not GITHUB_DISPATCH_TOKEN β€” GH reserves GITHUB_ prefix). All four mtaext placeholders resolve at deploy time via envsubst writing deploy/<env>.resolved.mtaext. Rotation: github-dispatch-pat-rotation.md.
  • Alert saves do NOT trigger rebuilds β€” Alerts are runtime-served via /api/alerts*. Rebuild classifier returns mode: 'none' for Alerts (_classify-rebuild-mode.js). Cache-bust on save is the only freshness mechanism; up-to-60s delay expected.

Content model quirks ​

  • Tag labels are DB-driven; slugs are the join key β€” Frontmatter carries raw slugs (software-product>sap-s-4hana). At Hugo build, fetch-tutorials.ts fetches slugβ†’label map from /build/tag-labels, emits displayTags (label) + displayTagSlugs (slug) into frontmatter + _nav.json. Navigator filter equality, license detection, topic categorization use displayTagSlugs; rendering uses displayTags. Labels admin-edited at /admin-ui/#tags-display. Missing slug falls back to lossy humanizeTag(). Seed from legacy AEM Solr: npm run seed-tag-labels.
  • Categories taxonomy is fixed in v1 β€” 8 categories seeded via db/data/com.sap.developers.ims-Categories.csv with stable UUIDs. Admins edit label/sortOrder/seedDescription but cannot add/remove.
  • Categories reclassify is destructive β€” Admin classifyCategories and per-OP "Classify this item" DELETE-then-INSERT junction rows. Manual category edits survive only until next reclassify run.
  • Tutorial slugs are lowercase canonical β€” Hugo emits lowercase URLs; read path 301-redirects mixed-case (see srv/lib/content-store.js:694). Write path lowercases via tutorialsTableInfo helper. Source markdown filenames may ship with capitals; never compare slugs to publish payload without .toLowerCase(). Mismatches manifest as "0 steps" on group SSR. Repair: scripts/repair-mixed-case-tutorial-duplicates.cjs.

AI features ​

  • AI code-check (issue #171, behind ChatSettings.codeCheckEnabled) β€” Author opt-in via [CODECHECK_N] blocks in rules.vr; trimmed spec ships in Hugo frontmatter, full spec in CodeCheckSpecs. Inline UI hits /api/codecheck (XSUAA, 30/hr/user, 5/5min/step); also checkCode Joule chat tool. Persistence: CodeCheckSubmissions. Spec: 2026-06-02-ai-code-check-spike-design.md.
  • AI-authored quizzes (issue #208, always-on as of #312) β€” Author opt-in via [AUTOAUTHOR_*] in rules.vr. Post-parse expansion in scripts/fetch-tutorials.ts. Per-tutorial content-hash cache at .tutorial-cache/<slug>.ai-quiz-cache.json. Hard cap default 200 LLM calls/build (AI_AUTHOR_BUILD_CAP). Bulk-seed: npm run seed-ai-quizzes. Model switch does NOT auto-invalidate cache β€” delete cache file manually. Kill-switch: set AI_AUTHOR_AICORE_SERVICE_KEY empty. Eval: scripts/evaluate-ai-quizzes.ts + scripts/aggregate-ai-quiz-eval.ts.
  • ChatSettings.ragEnabled β€” Feature flag for the getRelevantSteps tool. When toggling on first time, click "Seed Embeddings Now" in Joule Chat Settings tile. Reconciliation cron at minute 17 catches drift.
  • HYBRID_AI_TESTS=true to opt into category-classifier hybrid test β€” Default hybrid runs are $0/run. This env var enables test/hybrid/categories-classifier.test.js (one classify call per mission fixture).
  • AICORE_EXPLAINER_GENERATOR_DISABLED env var β€” Kill-switch for homepage explainer AI generation (#759). Set 'true' β†’ all three AdminService.generate*Explainers actions return HTTP 503. Hand-authored content survives.

Observability & load ​

  • Feature Flag Viewer (/admin-ui/#featureFlags) β€” read-only tile listing every runtime feature flag's live resolved state (effective value, winning layer, raw db/env/default). Source of truth: srv/lib/feature-flags/registry.js; a drift test (test/unit/feature-flags-registry.test.js) fails the build when a new *_ENABLED/*_WEIGHT env var or settings boolean is added unregistered. Known gap: the drift regex misses process.env[var] bracket-notation reads.
  • Observability metrics module (srv/lib/metrics.js, #805) β€” In-memory counters/gauges/reservoirs drained every 5min by srv/jobs/metrics-rollup-job.js into MetricSnapshots. Env flags: METRICS_ENABLED (default true; kill-switch), METRICS_DB_WRAP (default false; installs passive cds.db.run/cds.db.tx wrapper). Rollup does NOT use job-lock; retention (30d/90d) does. Live snapshot: /admin-ui/#metrics, GET /admin/getMetricsSnapshot(), GET /admin/metrics/live. See observability.md.
  • Load tests (test/load/) are k6, not Vitest, and do NOT run on PRs β€” Five scenarios drive deployed DEV. CI runs weekly (Mon 03:00 UTC) + manual. Never on push/PR (DEV quota isn't free). Thresholds in test/load/config.js; never hardcode ms in scenarios. Aborts if /content/hashes shows publish in flight. Runbook: load-testing.md.

Runtime env vars ​

  • CONTENT_API_KEY env var β€” Required for POST /content/publish and POST /content/rollback. Set in CI secrets and locally. Without it, publish returns 401.
  • SUBMISSION_SALT_SECRET env var β€” Required by srv/lib/feedback-salt.js for hashing submitter IPs on POST /feedback/submit. Express bridge returns 503 if missing.

Data privacy ​

  • @cap-js/data-privacy deferred, annotations shipped (#960) β€” Plugin install rolled back at 0.6.2 due to two cds build --production crashes. Annotation cleanups landed anyway. When retrying plugin adoption: verify cds build --production succeeds against schema FIRST; pick up Tasks 7/8/9 blueprints; do NOT re-annotate BranchDecisions as DataSubjectDetails. Spec: 2026-07-04-960-data-privacy-plugin-design.md.

Migration ​

  • Change tracking suppression for REST migrators β€” x-migration-mode: true header sent by migrate-reference-data.js and migrate-user-progress.js. HANA-to-HANA path (migrate-from-hana.js) still fires DB-level changelog triggers β€” see migration-from-ims.md for mitigations.

Personalization ​

  • Personalization endpoint MUST set X-Personalization: 1 and Cache-Control: private, no-store β€” the approuter is documented to never cache this header combination. Dropping either header silently allows a shared cache to serve one user's personalized payload to another user or to anonymous visitors. The smoke test (test/smoke/homepage-personalized.test.js) asserts both headers on every deployed environment.
  • Client-side ETag round-trip lives in sessionStorage['sap-devs-homepage-personalized'] β€” clearing sessionStorage forces the coordinator to fetch fresh (no If-None-Match header, 200 response). The session key is sap-devs-homepage-personalized; the bypass flag is sap-devs-homepage-default. Both are sessionStorage (not localStorage), so they clear on tab close.

Cron jobs ​

  • Reshuffle-video-rotation cron is TRUNCATE + INSERT β€” must run inside a single transaction. srv/jobs/reshuffle-video-rotation.js uses cds.tx to wrap DELETE FROM HomepageVideoRotation + bulk INSERT. If a future refactor splits these into two top-level db.run(...) calls, a mid-cycle failure will empty the sidecar and visitors will see anchor-only tiles until the next successful cron pass β€” silently. #1031.

  • kg-community-labels job skips stable clusters β€” nightly LLM spend is near-zero after first backlog (issue #1126). srv/jobs/kg-community-label-job.js runs at 04:12 UTC (after Louvain at 03:57). It upserts KgCommunityLabel rows keyed on communityFingerprint; if a community's memberSlugsHash (SHA-256 of sorted member slugs) matches the stored value, the row is skipped without an LLM call. First-run ramps the full backlog over several nights because communityLabelLlmBudgetPerDay (default 50) caps daily spend. The budget counter resets daily: communityLabelLlmCallsToday / communityLabelLlmCallsCountedOn on ChatSettings. If the job runs but Louvain has not yet populated KgCommunity, summaries is empty β†’ no LLM calls β†’ no error. Tool is gated by communityPeersEnabled on ChatSettings (default false), enabled via PATCH /admin/ChatSettings(<ID>) (Admin-gated; the /admin-ui/#joule Joule Settings page edits the same singleton but does not yet list this flag). No env var reads it.

Devtoberfest ​

  • Devtoberfest banner is admin-uploadable β€” per-DevtoberfestConfig DevtoberfestBanner composition (wide WebP BLOB, uploadBanner/clearBanner actions), served anonymously at GET /api/devtoberfest/banner for the active row; the Vue island renders it as the hero with the CTA overlaid lower-right, falling back to the CSS gradient header when unset. Full deploy required (schema + admin bundle + approuter). Spec: docs/superpowers/specs/2026-07-29-devtoberfest-banner-upload-design.md.

Tutorial Navigator ​

  • Navigator "Featured" rail is curated via /admin-ui/#/operations β†’ Featured Tasks β€” draft CRUD (pick items by title via FeaturedTaskCandidates value-help, unique per item, order defaults to next integer); SSR from browse.json's featured[] array (mission-curated or first-6-missions fallback when empty); live-rehydrated from GET /build/featured (ETag/304, 60s server cache, mixed tutorial/mission/group types); cache busts automatically on FeaturedTasks save/delete via resetFeaturedCache().

Top Gotchas β€” full detail (relocated from CLAUDE.md) ​

These paragraphs used to live inline in CLAUDE.md's "Top Gotchas" section. They were moved here verbatim to keep CLAUDE.md lean; the headline + load-bearing one-liner + a link back to this section remain in CLAUDE.md.

Build artifacts & lifecycle hooks ​

  • ignore-scripts=true silences postbuild:apps β€” build artifacts wired into it are NOT produced by local npm run build:all β€” the global npmrc ignore-scripts=true means npm lifecycle hooks never fire. The postbuild:apps hook is where the #1604 island-fingerprint step (build:island-manifest, which writes hugo/data/island_manifest.json) and 8 static guards live. During a local build:all, none of them run. Symptom class: fresh JS/CSS compiles (Vite emits navigator-<hash>.js) but is never referenced β€” hugo/layouts/partials/island-src.html falls back to the unhashed /js/<name>.js, Hugo bakes the stale path, and the approuter ships old bundles sitting next to the new ones. Merged fixes look "not deployed" even though the deploy succeeded. CI dodges this because deploy.yml/unit-tests.yml run npm run postbuild:apps as an explicit step (see deploy.yml:217-223 comment). Fix (2026-08-10): build:all now calls npm run build:island-manifest explicitly (not via the hook), and scripts/deploy-mta.cjs Step 2.5 fails the deploy if hugo/public/index.html bakes only unhashed island paths while a Vite manifest exists. Rule: any build artifact (not just a guard) needed for a correct ship must be an explicit step in build:all, never left to a post*/pre* lifecycle hook.
  • build:page-fallback is an explicit build:all step (NOT a lifecycle hook) β€” scripts/build-page-fallback.cjs copies in-scope page snapshots from hugo/public into srv/page-fallback/<key>.<ext> after build:hugo runs. Because ignore-scripts=true silences all pre*/post* hooks, it is wired as an explicit npm run build:page-fallback in the build:all chain, positioned right after build:hugo. If you add a new in-scope page to IN_SCOPE_PAGES in srv/lib/page-key-map.js, the snapshot is picked up automatically on the next full build. Snapshots are gitignored (srv/page-fallback/* except .gitkeep); the directory is committed empty and populated at build time.

Completions rollup (issue #1934) ​

  • Group/Mission completions are rollup-derived β€” the CAP rewrite never carried over the legacy IMS TUTORIALβ†’GROUPβ†’MISSION rollup, so GROUP/MISSION TaskRecords stopped being created at the 2026-08-10 cutover. srv/lib/completion-rollup.js recomputes parent group(s)/mission(s) after any TUTORIAL/PUZZLE/CHECKPOINT/PETOBERFEST completion β€” called from _updateTutorialProgress, resetTutorialProgress, the CHECKPOINT edge of createTaskRecord (developer-service), puzzle-service, and petoberfest-upload. Slot model: alt-groups (#172) collapse to one slot where any branch satisfies; a nested GROUP slot needs all its tutorials. Records key on (user_ID, taskLegacyId=<Groups|Missions>.legacyId, taskType) and are upserted (SELECT-then-UPDATE-or-INSERT) with stampSubmissionId so they carry the NGDS dedup key. NGDS auto-send fires on the β†’ COMPLETED edge (GROUP/MISSION are the only NGDS-eligible rollup types). The orchestrator never throws into the completion tx. Backfill (post-cutover only): scripts/backfill-group-mission-completions.mjs (bulk, --dry-run/--since/--user, no NGDS send) then scripts/backfill-ngds-send.mjs (rate-limited, resumable via ImsConfig 'ngds.backfill.cursor', honors env=prod + kill-switch + epoch + canonical-sapId; receiver dedups on submissionIdCompleted). Pre-cutover completions are intentionally NOT re-minted (legacy IMS credited them; the NGDS epoch guard suppresses them). completion-rollup.js is NOT a content-store.js dependency β†’ no srv-qa cp entry needed.

Content model β€” mutable ContentCurrent (Option B, #2017 / Workstream D) ​

  • Content served from mutable ContentCurrent, not the old ContentFiles snapshot-per-version β€” three env flags gate the migration, all default OFF, flip in order: CONTENT_DELTA_WRITE_ENABLED (publish dual-writes changed slugs β†’ ContentCurrent + append-only ContentHistory, fail-safe) β†’ seed via a full force rebuild (-f mode=full -f force-publish=true dual-writes all slugs; no separate migration) β†’ CONTENT_DELTA_READ_ENABLED (serve/readers hit ContentCurrent, per-slug fallback to ContentFiles) β†’ CONTENT_DELTA_SKIP_CARRYFORWARD (publish skips carryForwardUnchanged β†’ O(changed) publish; rollback then replays ContentHistory into ContentCurrent, not clear+fallback). Measured DEV: publish commit ~62sβ†’973ms (PROD carry-forward was ~95s @ 11k files). Serve source header X-Content-Source: db-current (ContentCurrent) vs db (legacy). Revert = flip SKIP_CARRYFORWARD off + cf restart. Flags in srv/lib/feature-flags/registry.js; readers via resolveContentBlob (srv/lib/content-store.js). LOB reads stay raw db.run(). Cutover cleanup (delete carryForwardUnchanged + ContentFiles) deferred to a release after PROD soak.

NGDS auto-send ​

  • NGDS auto-send is PROD-only + DB-gated (double gate) β€” automatic push of task completions to NGDS (badging/gamification) fires from srv/lib/ngds-autosend.js#maybeAutoSendCompletion, called at the two completion transition points in srv/developer-service.js (_updateTutorialProgress + createTaskRecord). Sends ONLY when BOTH gates pass: (1) CF space_name==='prod' (resolveDeployEnvironment, not spoofable headers) AND (2) ImsConfig key ngds.autosend.enabled==='true' (admin kill-switch via AdminService.toggleNgdsAutoSend; 60s flag cache, busted on toggle). Edge-only (fires on the transition β†’ COMPLETED, never on repeat saves) and task-type-allowlisted to TUTORIAL/GROUP/MISSION (legacy parity β€” PUZZLE/PETOBERFEST/STEP never sent). Fails CLOSED (config read error β†’ disabled) and never throws into the completion tx (a send fault queues in NGDSFailedMessages for the 2h retry job). Bulk recompute (raw HANA MERGE) + migration (raw SQL) bypass the service layer, so they cannot flood NGDS. Payload shape itself is the legacy MessageModel (#1473). Default OFF in every env; enable in PROD via the admin toggle. Status: AdminService.getNgdsAutoSendConfig() returns {enabled, environment, effective}.

Knowledge graph feature flags ​

All default OFF and DEV-only unless noted. Toggles fail-open on every fault path.

  • KG_PAGERANK_ENABLED (issue #916) β€” when 'true', rankNeighborhood in srv/knowledge-graph-service.js multiplicatively blends per-tutorial PageRank (weight *= 1 + Ξ± Γ— normPR) into all three tutorial-targeted arms (prerequisitesOf, sharedConcepts, whatToLearnNext) and sorts teaches by concept-side PageRank. Scores recomputed nightly at 03:53 UTC by srv/jobs/kg-pagerank-job.js β€” PageRank runs in Node.js (not HANA GraphScript β€” that engine ships no PageRank primitive) over KG_PG_VERTICES_V + KG_PG_EDGES_V, materialized into ConceptRank/TutorialRank sidecars. Fail-opens on every fault path (missing sidecars, HANA hiccup, empty maps β†’ multiplier collapses to 1.0). Toggle: cf set-env tutorials-srv KG_PAGERANK_ENABLED true && cf restart tutorials-srv. Blend strength via KG_PAGERANK_ALPHA (default 1.0 β†’ weights grow at most 2Γ—).
  • KG_WCC_ISOLATION_THRESHOLD (issue #918) β€” nightly srv/jobs/kg-wcc-job.js runs at 04:07 UTC and materializes rows into KgIsolation for concept + tutorial vertices whose weakly-connected-component size ≀ threshold. Default 1; 0 empties the table on the next run (effectively disables the "Isolated" red-badge column on the admin Concepts + Tutorials LRs). Compute is Node.js union-find over KG_PG_VERTICES_V + KG_PG_EDGES_V β€” same reason as #916 that HANA GraphScript ships no WCC primitive (SCC yes, WCC no). Fail-quiet at read time: the after('READ') decorators on KnowledgeGraphService.Concepts and AdminService.Tutorials catch any SELECT throw and leave isolated unset β€” Fiori renders null boolean as no badge. Toggle: cf set-env tutorials-srv KG_WCC_ISOLATION_THRESHOLD 2 && cf restart tutorials-srv (or 0 to disable).
  • KG_ONDEMAND_ENABLED / KnowledgeGraphSettings.onDemandExtractionEnabled (issue #948) β€” when true, expandSearchConcepts fire-and-forgets an enqueue on zero-seed queries; a new 2-minute cron kg-ondemand-drain cosine-ranks the corpus and extracts concepts from top-K tutorials via extractConceptsFromTutorial. Coalesces near-duplicate queries; per-user (default 3/hr) and global (default 20/hr) rate-limit caps. Env knobs: KG_ONDEMAND_USER_MAX_PER_HOUR, KG_ONDEMAND_GLOBAL_MAX_PER_HOUR, KG_ONDEMAND_DRAIN_BATCH (default 3), KG_ONDEMAND_TUTORIALS_PER_REQ (default 5), KG_ONDEMAND_MAX_ATTEMPTS (default 3). Admin surface: /admin-ui/#kgOnDemand. Drain uses try/finally to recover stuck RUNNING rows on UPDATE failure. On-demand extraction is now link-only (#1115) β€” it attaches existing concepts (0.7 floor) but never mints. Toggle: flip onDemandExtractionEnabled=true at /admin-ui/#kg-settings (or cf set-env tutorials-srv KG_ONDEMAND_ENABLED true && cf restart tutorials-srv).
  • KG community detection (issue #917) β€” Louvain community detection over KG_PG_WORKSPACE runs nightly at 03:57 UTC (srv/jobs/kg-communities-job.js) via HANA GraphScript Communities_Louvain in db/src/procedures/KG_LOUVAIN_GRAPH.hdbprocedure. Memberships materialize into the KgCommunity sidecar (db/knowledge-graph-communities.cds). Admin surface: /admin-ui/#kgCommunities renders a FE List Report (aggregated summary) + Object Page over AdminService.KgCommunities and AdminService.KgCommunityMembers. promoteCommunityToMission(communityId, missionSlug, title) action (SuperAdmin-gated) drafts a Missions row + CompletionPaths + CompletionPathItems sorted Tutorials.title ASC, with Missions.sourceKgCommunityId set so already-promoted communities can be filtered out. Nightly job fail-opens; empty sidecar renders as FE "No data", never a 500. No env flag β€” tile is always visible to XSUAA Tutorial.Author scope. DEV-only in v1; PROD rollout deferred. Metrics: kg_communities_{duration_ms,count,max_size,failures}.
  • KG_RETIRE_ORPHANS_ENABLED / KG_RETIRE_ORPHANS_AGE_DAYS (issue #1115) β€” nightly srv/jobs/kg-retire-orphans-job.js at 04:37 UTC flips Concepts.status ACTIVEβ†’RETIRED for concepts with zero links across all 10 link tables and firstSeenAt older than KG_RETIRE_ORPHANS_AGE_DAYS (default 14). RETIRED falls out of every read path (all filter status='ACTIVE' positively). First-run retirement ramps rather than purging instantly. Reversible: cf set-env tutorials-srv KG_RETIRE_ORPHANS_ENABLED false (off) or bulk UPDATE Concepts SET status='ACTIVE' WHERE status='RETIRED' (data revert). A re-proposed retired slug is reactivated in-tx by kg-merge-on-write.js (retiredBySlug + action:'reactivated').
  • KG community peers + community labeling (issue #1126) β€” communityPeersEnabled on ChatSettings (default false) gates the findCommunityPeers Joule tool (srv/lib/kg/joule-tool-community-peers.js). When enabled, the tool accepts a tutorial_slug, looks up the anchor's communityFingerprint in KgCommunity, and returns sibling tutorials from the same Louvain cluster plus the LLM-generated cluster label from KgCommunityLabel. Nightly kg-community-labels job (srv/jobs/kg-community-label-job.js) runs at 04:12 UTC (after Louvain at 03:57) and LLM-names each community with β‰₯ 2 tutorials. Identity key is communityFingerprint (String(64)); skip-key is memberSlugsHash (SHA-256 of sorted slugs) β€” stable member sets incur zero LLM calls. Daily budget is communityLabelLlmBudgetPerDay on ChatSettings (default 50). Fail-open per community. Toggle: communityPeersEnabled is a ChatSettings column (NOT an env var), enabled by an Admin via PATCH /admin/ChatSettings(<ID>) on the AdminService singleton (the /admin-ui/#joule Joule Settings page edits the same entity but does not yet surface this flag). DEV-only until PROD Louvain data verifies. Metrics: kg_community_label_{duration_ms,labeled,skipped,failures}.
  • KG_COMMUNITY_WEIGHT (issue #1171) β€” when > 0, SearchService.before('READ') appends a SECOND additive rank term + KG_COMMUNITY_WEIGHT * (case slug when '<peer>' then 1.0 else 0 end) alongside the existing concept-overlap KG_WEIGHT (#945). Peers are tutorials sharing a Louvain communityFingerprint (#917/#1126) with the top-COMMUNITY_TOP_K (5) concept-overlap hits. Default 0 (OFF) β†’ buildCommunityRankFragment in srv/lib/search-kg-signal.js short-circuits before any DB fetch and the rank SQL is byte-identical to the #945 formula. Only fires when ChatSettings.searchKgRerankEnabled=true. Fail-open (any DB throw β†’ term collapses to ''). Membership fetched packet-safe (≀5 fingerprints .in(), members capped 200, filtered in Node). Regression harness + churn report at test/harness/community-rank-churn*; do NOT enable in any env before the ON-vs-OFF churn is hand-reviewed. Toggle: cf set-env tutorials-srv KG_COMMUNITY_WEIGHT 1.5 && cf restart tutorials-srv (with searchKgRerankEnabled=true).
  • KG_COMMUNITY_COVERAGE_NUDGE_THRESHOLD (issue #1172) β€” the after('READ','KgCommunities') decorator in srv/admin-service.js computes, per community at read time, mission-coverage % + dominant published mission + orphan-tutorial count (helper: srv/lib/kg-community-coverage.js) and populates virtual fields on AdminService.KgCommunities. Coverage is published-missions-only and the % denominator is tutorial members only (concept/tag-only communities render N/A, not 0%). coverageHigh (>= threshold, default 70) is the single server-computed flag driving both the LR criticality badge and the FE promote-time MessageBox.warning ("~X% already in <mission> β€” extend instead?"). Fail-quiet in its own try/catch: any throw β†’ warn-log, fields unset, no badge, never a 500. No new job/table/migration β€” computed live. Packet-safe: the covered-slug .in() is chunked at 500. SuperAdmin gate on promoteCommunityToMission unchanged; the nudge is advisory. Override: cf set-env tutorials-srv KG_COMMUNITY_COVERAGE_NUDGE_THRESHOLD 80 && cf restart tutorials-srv. DEV-only until the #1126 PROD Louvain rollout lands.
  • Cluster-level Q&A in Joule (issue #1173) β€” describeCommunity Joule tool (srv/lib/kg/joule-tool-describe-community.js) answers "what's the AI cluster?" / "everything around RAP" by resolving a free-text topic to a labeled Louvain community. LLM-side matching: communityCatalogLayer in srv/lib/chat-context.js injects the labeled-cluster catalog (from KgCommunityLabel, cached ~5min, cap 40) into the learner system prompt only when communityPeersEnabled is true; the model passes the chosen label as matched_label, and matchLabel (srv/lib/kg/community-label-match.js, pure) does case-insensitive exact match + token-overlap fallback + ambiguity detection. Reuses the existing communityPeersEnabled flag (NO new flag/schema), the community-peers-cards SSE frame + renderCommunityPeersCards render path, and the extracted resolveCommunityMembers helper (srv/lib/kg/community-members.js). Fail-open throughout (never 500). Gotcha: buildSystemPromptLines in chat-orchestrator.js is DEAD at runtime β€” buildSystemPrompt (chat-context.js) never calls it; the live guidance ships via communityCatalogLayer. DEV-only until PROD Louvain data verifies.

HCQL protocol adapter (issue #995) ​

  • HCQL protocol adapter β€” CAP 10 beta feature. @hcql annotation on 9 read-heavy services (AdminService, AuthorService, AnalyticsService, ExportsService, ConsolidationService, KnowledgeGraphService, HomepageService, SearchService, DeveloperService) in srv/hcql-enablement.cds exposes each service at its existing OData URL to also accept CQN SELECT bodies (HCQL and OData share URLs; dispatch is by request-body shape). Auth inherited from existing @readonly/@requires. Writes intentionally unsupported (beta not stable cross-runtime). Runtime hazard: CAP 10.0.3 exits the process on malformed CQN β€” do not expose to untrusted clients until CAP hardens the adapter. Kill switch: delete srv/hcql-enablement.cds, cds build --production, redeploy. Full reference: hcql-support.md.

Freshness detector ​

  • Freshness detector grounding needs the corpus-embedding backfill β€” the checkFreshness/freshness-scan engine cosine-searches ApiDocs/Samples embeddings. Those columns are populated by srv/jobs/freshness-corpus-embedding-job.js (nightly 17 3 + on-demand runJob). Until it runs in an env, grounding returns nothing and every API-obsolescence claim degrades to confidence: Low (fail-open, by design). LLM calls use the SAP AI SDK directly (@sap-ai-sdk/orchestration, forced tool-call), NOT @cap-js/ai; unit tests inject globalThis.__FRESHNESS_TEST_IMPL__. Bulk scan gated by FRESHNESS_SCAN_ENABLED (default OFF). Tutorial markdown is sourced from ContentFiles.sourceContent via getTutorialSource(slug) in srv/lib/content-store.js β€” NOT from Steps.description (Steps are never populated with step markdown; reading Steps would yield nothing). Findings carry a global codeBlockIndex across the whole-tutorial markdown β€” per-step attribution is deferred because the persisted source is not split per step.

Signed provenance envelope (issue #2245) ​

  • PROVENANCE_ENVELOPE_ENABLED is a DB config flag (ImsConfig), default OFF, DEV-first β€” controlled via ImsConfig key flag.provenance.envelope (registered in srv/lib/feature-flags/registry.js). When OFF, GET /content/tutorials/:slug/provenance returns 404 and GET /.well-known/tutorial-provenance/jwks.json returns 404. No env var alternative; never store the signing key as an env var directly (use the credstore β€” see below). Flip via /admin-ui/#featureFlags (DEV); confirm PROD behaviour before enabling there.

  • Two anonymous public endpoints β€” both are plain Express routes registered in srv/server.js, intentionally outside any CAP service. @requires / @restrict do not apply. Both are read-only content-distribution endpoints, safe to serve unauthenticated:

    • GET /content/tutorials/:slug/provenance β€” returns { jws, jwks_url }. jws is a compact Ed25519-signed JWS (JWT serialisation via jose's SignJWT) whose payload attests { sub, contentHash, sourceCommit, builtAt, freshness: { confidence, runAt, openHighCount, openMediumCount } }. Returns 404 when the flag is OFF, 404 when the slug is unknown, or 503 on signing failure (fail-open: content still serves normally).
    • GET /.well-known/tutorial-provenance/jwks.json β€” returns the Ed25519 JWKS { keys: [...] } for out-of-band JWS verification. Returns 404 when the flag is OFF. Key ID (kid) in the JWKS matches the kid header in every issued JWS, enabling key rotation without re-verifying old tokens.
  • PROVENANCE_SIGNING_KEY credstore secret β€” the Ed25519 private key (PKCS8 PEM format) is stored in the target environment's BTP Credential Store as PROVENANCE_SIGNING_KEY. srv/lib/provenance-keys.js reads it credstore-first via resolveSecret('PROVENANCE_SIGNING_KEY') (credstore β†’ process.env fallback β†’ null), the same seam as CONTENT_API_KEY β€” a CF credstore binding does not auto-inject the value into process.env, so the env fallback only fires for local/dev where the PEM is exported directly. (Before #2308 this module read process.env directly and never saw a credstore-provisioned key β†’ empty JWKS + 503 on PROD.) Never commit a key or paste one into .mtaext, env files, or source. Rotation: generate a new key (see below), store it via /admin-ui/#secrets (the per-env credstore rotation flow), then cf restart tutorials-srv β€” the new kid propagates to the JWKS automatically on next request. Old JWS tokens signed with the retired key will fail verification once the key is removed from the JWKS; that is expected.

  • Generating a DEV signing key β€” run locally and copy the PEM output, then paste it into /admin-ui/#secrets as PROVENANCE_SIGNING_KEY on the target env. Never write the output to a file you might commit.

    bash
    node -e "import('jose').then(async j=>{const {privateKey}=await j.generateKeyPair('EdDSA',{crv:'Ed25519',extractable:true});console.log(await j.exportPKCS8(privateKey))})"
  • Freshness confidence values β€” derived by deriveConfidence in srv/lib/provenance-freshness.js. Possible values: high (freshness report DONE within 30 days, no open findings), medium (DONE but 30–90 days old or has open medium-severity findings), low (DONE but > 90 days old or any open high-severity finding), unknown (no freshness report or report status not DONE). Corpus-embedding backfill must have run before any value other than unknown can be returned β€” see "Freshness detector" above.

  • Advisory headers on the HTML serve path β€” when the flag is ON, GET /content/tutorials/:slug also sets X-Freshness-Confidence: <value> and X-Content-Provenance: <jwks_url> on the HTML response. These are advisory only; caching behaviour is unchanged. Any error deriving the advisory is swallowed silently (fail-open).

  • sourceCommit plumbing β€” ContentCurrent.sourceCommit (String(64)) is populated by the publish pipeline via the source_commit field in the publish payload; scripts/fetch-tutorials.ts threads the HEAD commit SHA through to the publish client. On older published content the column is NULL; the provenance JWS payload will carry sourceCommit: null in that case, which is valid.