Operatør delegerte de 4 åpne beslutningene (mandat: profesjonell, til å stole på). Alle besluttet; (a) fetch fikk binding robusthetskontrakt (response.ok/timeout/429-Retry-After), (c) removed pinnet til full-enum-kjøringer (last_full_enum-kadens) for å unngå falsk-positiv flom. §7-gatekriterier for C3.1/C3.2 utvidet. Neste = C3.1 (TDD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
C3 — Kurs-deteksjon via Learn Platform API (spec)
Spec for krav 3 (kurs-deteksjon). Skrevet 2026-06-23 (Sesjon 30) på det verifiserte grunnlaget i c3-course-detection-spike.md. Status: GODKJENT (S31). De fire åpne beslutningene i §8 er delegert til arkitekt av operatør (mandat: «profesjonell plugin, til å stole på») og besluttet — to med skjerpende robusthets-/korrekthetskrav (§8 (a) fetch-kontrakt, §8 (c) full-enum-pin). C3.1 er klarert til oppstart. Stier relative til plugin-rot.
0. Mål og avgrensning
Mål: Oppdage at Microsoft har publisert nye eller endrede treningskurs (modules + learning paths) innenfor de produktene KB-en dekker, slik at operatøren kan vurdere om temaet bør dekkes i kunnskapsbasen.
Ikke-mål (eksplisitt):
- IKKE auto-ingest av kursinnhold som KB-filer. Et kurs er et signal om at et tema finnes — ikke en doc-side som transformeres. (Spike §29: «oppdage … som bør dekkes», ikke ingest.)
- IKKE å erstatte den eksisterende sitemap-baserte doc-discovery (
discover-new-urls.mjs). C3 er et parallelt, uavhengig spor på en helt annen datakilde (Platform API, ikke sitemaps). - IKKE å skrive til KB (
skills/) eller tildecisions.jsonfra detektoren. Se §2 (arkitektur-invariant).
Datakilde (besluttet S29): Microsoft Learn Platform API (/api/v1, Entra ID app-only). IKKE legacy /api/catalog/ (anonym, deprekeres «starting June 2026»), IKKE microsoft-learn-MCP (docs-only, ingen dato/produkt-filter). Begrunnelse i spike §6.
1. Verifisert API-grunnlag (kort — full versjon i spike)
Alt under er empirisk verifisert i S29 (spike §14–§26):
- Base:
https://learn.microsoft.com/api/v1· påkrevd query?api-version=2023-11-01-preview(kun preview finnes). - Auth: Entra client-credentials → bearer-token, scope
https://learn.microsoft.com/.default. Token-endepunkt er standard Entra v2.0:https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token(tenant-id, client-id, client-secret hentes fra Keychain — se §3). Verifisert: token → HTTP 200 mot/api/v1/modules. - Endepunkter (per-type, ikke kombinert):
/api/v1/modules✅,/api/v1/learning-paths✅ (kebab-case)./api/v1/productsfinnes IKKE (404). - Paginering: respons
{"value":[…], "nextLink":…}— cursor vianextLink;maxpagesize≤ 100 (default 30). - Felt vi bruker:
id(UID — stabil nøkkel),title,updatedAt(ISO 8601),products[].id,url. Learning path: samme +modules[]. - Filter: flere params = AND; komma-separert i én param = OR. Produkt-filter virker server-side (
products=azure-openai→ 34 treff = ekte filtrering).
To gotchas spec-en pinner (verifisert empirisk — MÅ håndteres i kode)
updatedAt.gtkrever full ISO 8601 datetime.updatedAt.gt=2026-01-01T00:00:00Z→ virker. Dato-onlyupdatedAt.gt=2026-01-01→ fail-closed (0 treff), selv om doc-eksempelet viser dato-only. Currency-cursoren må alltid serialiseres som full ISO medZ.- Produkt-slugs må enumereres fra live data. Doc-eksempelet
azure-ai-foundry→ 0 treff (død slug). Ekte slugs samles ved å paginere moduler og hente distinctproducts[].id(siden/api/v1/productsikke finnes).azure-openai(34) bekreftet levende. - Locale: bruk
en-ussom primær (nb-nofaller tilbake til en-us-metadata der oversettelse mangler).
2. Arkitektur-invariant (binding — gjelder C3 uendret)
Eksisterende kode håndhever at deteksjon aldri skriver decisions.json eller skills/ — kun via operatør-gate. Dette er ikke en konvensjon men en testet invariant:
discover-new-urls.mjs:12-14importerer kunloadDecisions, isDecided— aldrisaveDecisions. Håndhevet avtests/kb-update/test-discover-invariant.test.mjs(grepper import-linjene,assert.doesNotMatchpåsaveDecisions/atomic-write/backup).run-detection.mjskjører kunexecFileSync('node', …)på allow-listede steg — kan strukturelt aldri spawneclaude(Claude-fri ToS-flate,detection-schedule.mjs:1-9).
C3 følger samme to-stegs-mønster som discover-new-urls:
STEG 1 (Claude-fri, planlagt — detektoren) STEG 2 (operatør-gated, Claude-i-loop — gaten)
───────────────────────────────────────── ───────────────────────────────────────────────
detect-courses.mjs /architect:kb-update (utvidet seksjon)
• leser Keychain-creds • leser course-detection-report.json
• token → paginer API (produkt + updatedAt.gt) • presenterer nye/endrede kurs for operatør
• diff mot course-registry.json • operatør approve/reject
• SKRIVER: • SKRIVER leads til decisions.json (courses-koll.)
- course-detection-report.json (kandidater) via recordCourseLead + saveDecisions
- course-registry.json (egen diff-state)
• IMPORTERER ALDRI saveDecisions
course-registry.json er detektorens egen sporings-state (diff-baseline), på samme måte som url-registry.json skrives av poll-sitemaps.mjs. Det er hverken KB eller decisions.json → å skrive det bryter ikke invarianten. (Eksplisitt: invarianten forbyr skriving til skills/ og til gatens decisions.json — ikke detektorens private state.)
3. Hemmelighets-håndtering (Keychain, Claude-fri)
Detektoren leser de tre creds direkte fra macOS Keychain ved kjøretid — aldri fra repo eller env (strengere enn ~/.zshenv-mønster; spike §12).
// lib/keychain.mjs (NY) — fail-soft: returnerer null hvis item mangler.
import { execFileSync } from 'node:child_process';
export function readSecret(service, account = 'ktg') {
try {
return execFileSync('security',
['find-generic-password', '-s', service, '-a', account, '-w'],
{ encoding: 'utf8' }).trim();
} catch { return null; } // item mangler / ikke macOS → caller fail-softer
}
Items (account ktg, satt opp S29): learn-platform-client-secret, learn-platform-tenant-id, learn-platform-client-id. Client-secret utløper ~2028-06 (eneste vedlikeholdspunkt).
Fail-soft-kontrakt: mangler én av de tre creds, skal detect-courses.mjs logge årsak, skrive en skipped-rapport og exit 0 — aldri kræsje deteksjons-pipelinen for en bruker som ikke har satt opp Platform API. (Begrunnelse: C3 er opt-in; en bruker uten Entra-app skal ikke få en rød pipeline.)
Merk (literal-IDer): tenant-id/client-id/secret skal ALDRI committes — heller ikke i denne spec-en, test-fixtures eller logger. Tester injiserer falske creds via en stub.
4. Data-design
4.1 data/course-registry.json — detektorens diff-state (NY)
Detektorens private «known-UID»-register. Skrives av detektoren, lest ved neste kjøring. Mønster = url-registry.json (atomisk skriving via registry-io-stil).
{
"version": 1,
"created_at": "2026-06-23T12:00:00Z",
"last_run": "2026-06-23T12:00:00Z", // FULL ISO — cursor for updatedAt.gt neste kjøring (gotcha #1)
"last_full_enum": "2026-06-23T12:00:00Z", // FULL ISO — siste full enumerering (uten updatedAt.gt). Styrer removed-beregning (§4.2) + full-enum-kadens (≥30 d)
"courses": {
"<module-or-path-uid>": {
"type": "module", // "module" | "learning-path"
"title": "...",
"url": "https://learn.microsoft.com/training/modules/...",
"products": ["azure-openai"],
"updated_at": "2026-06-20T08:00:00Z", // kursets updatedAt sist sett
"first_seen": "2026-06-23T12:00:00Z",
"last_seen": "2026-06-23T12:00:00Z"
}
}
}
- Første kjøring (ingen
last_run): full paginering utenupdatedAt.gt→ etablerer baseline, setterlast_run = nowoglast_full_enum = now. Ingen leads emitteres på baseline-kjøringen (alt er «kjent» fra start). Besluttet: §8 (b). - Inkrementelle kjøringer (normalfall):
updatedAt.gt=<last_run>henter kun endrede; diff klassifiserer new/updated.removedberegnes IKKE her (etupdatedAt.gt-filtrert svar utelater alt uendret → naiv removed-diff ville falskt flagge nesten hele registeret; se §4.2).last_run = now. - Periodisk full enumerering (når
last_full_enummangler eller er eldre enn 30 d): full paginering utenupdatedAt.gt→ diff gir new/updated + removed (full API-sett vs. register). Setterlast_run = nowoglast_full_enum = now. Dette er den eneste kjøringstypen som kan oppdage retirerte kurs.
4.2 Diff-semantikk (spike §24)
- ny UID (i API, ikke i register) → lead
kind: "new". - endret
updatedAt(UID i begge, nyere timestamp) → leadkind: "updated". - forsvunnet UID (i register, ikke i API-svar) →
kind: "removed". Behandles som informasjonssignal i rapporten, IKKE som ledger-lead (et retirert kurs er ikke et «tema å dekke»). Korrekthets-pin (besluttet §8 (c)):removedberegnes KUN på full-enumereringskjøringer (§4.1) — aldri på inkrementelleupdatedAt.gt-kjøringer, der «ikke i svar» er sant for alt uendret og ville gi massiv falsk-positiv flom. Inkrementelle kjøringer setterremoved: [].
4.3 data/course-detection-report.json — STEG 1-output (NY)
Analogt med discovery-report.json:
{
"generated_at": "2026-06-23T12:00:00Z",
"status": "ok", // "ok" | "skipped" (creds mangler) | "error"
"skipped_reason": null,
"new": [ { "uid", "title", "url", "products", "suggested_skill", "suggested_category", "updated_at" } ],
"updated": [ /* samme form */ ],
"removed": [ { "uid", "title", "url" } ],
"counts": { "new": 0, "updated": 0, "removed": 0 }
}
4.4 Produkt-slug→skill-mapping — config i domain-taxonomy.json (UTVIDELSE)
Mappingen er config, ikke hardkoding (spike §30). Den hører hjemme i lag-0-taksonomien, som er den bevisst konsoliderte ene sannhetskilden (domain-taxonomy.json-provenance: «Consolidates four previously-divergent taxonomies into one»). Ny topp-nivå-seksjon:
"course_products": {
"azure-openai": { "skill": "ms-ai-engineering", "category": "azure-ai-services" },
"microsoft-copilot-studio": { "skill": "ms-ai-advisor", "category": "platforms" },
"power-automate": { "skill": "ms-ai-advisor", "category": "platforms" },
"ai-builder": { "skill": "ms-ai-advisor", "category": "platforms" }
// … finaliseres i C3.3 fra enumererte live-slugs (gotcha #2)
}
Ny accessor i taxonomy.mjs: makeCourseClassifier(tax) → (products[]) => {skill, category} | null (første in-domain produkt vinner; ukjent slug → null = ikke in-domain → ikke lead). Speiler makeClassifier.
4.5 Ledger-utvidelse — courses-kolleksjon i decisions.json (STEG 2)
decisions.json har i dag to parallelle kolleksjoner: decisions (URL-nøklet, Spor A) og actions (skill-nøklet, Spor B). Kurs er en tredje entitetstype (UID-nøklet) → får en tredje kolleksjon, additivt:
{
"version": 1, "updated_at": null,
"decisions": { /* url-keyed, uendret */ },
"actions": { /* skill-keyed, uendret */ },
"courses": { // NY — uid-keyed
"<uid>": {
"kind": "new", // "new" | "updated"
"status": "pending", // pending | approved | rejected (dedup-policy A)
"title": "...", "url": "...", "products": ["azure-openai"],
"suggested_skill": "ms-ai-engineering",
"updated_at": "2026-06-20T08:00:00Z",
"detected_at": "2026-06-23T12:00:00Z",
"decided_at": null, "note": ""
}
}
}
Nye rene helpers i decisions-io.mjs (speiler recordAction/setActionStatus/isActionDecided):
recordCourseLead(ledger, uid, lead), setCourseLeadStatus(ledger, uid, status, decided_at), isCourseLeadDecided(ledger, uid). createLedger() får courses: {}; loadDecisions defaulter ledger.courses ??= {} (bakoverkompatibelt med eksisterende ledgere uten feltet).
Hvorfor egen kolleksjon, ikke gjenbruk av decisions (URL-nøklet)? Et godkjent kurs-lead må ALDRI havne i doc-transform-pipelinen (fetch→transform→KB-fil) — det ville være auto-ingest, som er eksplisitt ikke-mål. En egen courses-kolleksjon holder kurs-leads strukturelt utenfor decisions-strømmen som apply-pathen leser. Dedup er per UID (stabil nøkkel; URL kan endres). Alternativ vurdert og forkastet: gjenbruke decisions med en source-diskriminator + skip i apply-path — mer kobling, høyere risiko for utilsiktet ingest.
5. Scheduler-integrasjon (Claude-fri, opt-in)
Nytt steg i den frosne DETECTION_STEPS (detection-schedule.mjs:36-41), gated av et nytt flagg — speiler skillLifecycle-mønsteret:
{ name: 'detect-courses', dir: 'kb-update', script: 'detect-courses.mjs', args: [], courseDetection: true },
run-detection.mjs filtrerer steget bort når config.include_course_detection !== true (samme filter-linje som i dag bruker include_skill_lifecycle).
Ny config-nøkkel include_course_detection i DEFAULT_SCHEDULE_CONFIG — default false (krever Keychain-creds som ikke alle har; opt-in inni opt-in). Plumbing additivt: coerce() får en boolean-gren, serializeScheduleConfig får linjen, K5 round-trip-testen utvides. (Eier-CLI write-schedule-config.mjs kan senere eksponere et --courses-flagg; ikke nødvendig for MVP — operatør kan sette nøkkelen manuelt i ~/.claude/ms-ai-architect/ms-ai-architect.local.md.)
6. Network-klient (lib/learn-api.mjs, NY)
Claude-fri nettverkslag (skriver ingen filer). Eksisterende HTTP-mønster er node:https.get med retry (sitemap-stream.mjs), men det håndterer ikke bearer-token/JSON/POST. C3-klienten bruker native fetch (innebygd og global i Node 25 → null ny avhengighet, holder zero-dependency-konvensjonen). Besluttet: §8 (a).
Robusthetskontrakt (binding — skiller «funker» fra «profesjonell», testes i C3.1): rå fetch har to fallgruver som wrapperen MÅ lukke:
fetchkaster ikke på HTTP 4xx/5xx (kun på nettverksfeil) → wrapperen sjekkerresponse.okeksplisitt og behandler ikke-ok som feil.fetchhar ingen default timeout → hver request fårAbortSignal.timeout(<ms>).
Retry-policy: 3 forsøk på 429 + 5xx med backoff som respekterer Retry-After-headeren (Microsofts API rate-limiter; fast backoff ellers). 4xx ≠ 429 (f.eks. 401/403) er ikke retrybart → caster umiddelbart. Fail-closed på vedvarende feil (caster opp så detect-courses fail-softer til error-rapport + exit 0). Token hentes ÉN gang per kjøring (client-credentials-token varer ~1 t) og gjenbrukes på tvers av paginering.
Eksporterte funksjoner (skisse):
export async function getToken({ tenantId, clientId, clientSecret }) // client-credentials → bearer
export async function* paginate(endpoint, params, token) // yield items via nextLink
// endpoint ∈ {'modules','learning-paths'}; params bygger ?api-version + products + updatedAt.gt
export function buildUpdatedAtGt(iso) // PIN: full ISO m/ Z (gotcha #1) — kaster på dato-only input
7. Faseplan med verifiserbare kriterier
Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart kriterium før neste. Subagenter committer aldri; validate før commit.
| Fase | Leveranse | Verifiserbart gate-kriterium (kjørbart) |
|---|---|---|
| 0 ✅ | Auth-oppsett + API-kartlegging | FERDIG (S29). Token → 200 verifisert. |
| C3.1 | lib/keychain.mjs + lib/learn-api.mjs (token, paginer, filter-bygging) |
node --test tests/kb-update/test-learn-api.test.mjs grønn: token-body-bygging, buildUpdatedAtGt kaster på dato-only + emitterer full ISO, nextLink-paginering, produkt-param. Robusthetskontrakt (§6, besluttet §8 (a)): test at ikke-ok HTTP-status (404/500, stub) kaster (ikke svelges), at 429 retries og respekterer Retry-After, at 4xx≠429 ikke retries, og at timeout (AbortSignal) caster. Claude-fri-invariant-test grønn (ingen claude/anthropic i fila). Manuell live-probe (utenfor CI): henter ≥1 modul med ekte creds. |
| C3.2 | lib/course-diff.mjs (ren) + course-registry.json-schema (m/ last_full_enum) + load/save |
test-course-diff.test.mjs grønn: new/updated/no-op + tom-baseline. Full-enum-pin (besluttet §8 (c)): test at removed KUN emitteres på full-enum-modus, at inkrementell modus gir removed: [] selv når register-UIDs mangler i (filtrert) svar, og at full-enum trigges når last_full_enum mangler/er >30 d. Round-trip persistens-test. Diff-modulen er ren (invariant-test: ingen write-util-import). |
| C3.3 | course_products i domain-taxonomy.json (slugs enumerert fra live data) + makeCourseClassifier |
test-taxonomy.test.mjs utvidet grønn: in-domain slug → skill, ukjent → null. Enumererings-kjøring dokumentert i commit (distinct products[].id ∩ in-domain). refTall/K-counts uendret (eval --json). |
| C3.4 | detect-courses.mjs (binder C3.1–C3.3) + DETECTION_STEPS-steg + config-nøkkel |
Invariant-test (test-detect-courses-invariant): ingen saveDecisions-import. Fail-soft-test: creds=null → status:"skipped" + exit 0. Report-shape-test. DETECTION_STEPS-filter-test (include_course_detection:false ⇒ steg droppet). Hele tests/kb-update/*.test.mjs fortsatt grønn (213+). |
| C3.5 | courses-kolleksjon i decisions-io.mjs + /architect:kb-update-gate-seksjon |
test-decisions-io/test-decisions-actions utvidet grønn: recordCourseLead/setCourseLeadStatus/isCourseLeadDecided + dedup-policy-A per UID + bakoverkompat (ledger uten courses). Eksisterende ledger-tester urørt grønn. Kommando-dry-run viser leads, ingen transform/ingest trigges. |
| C3.6 | SessionStart-surfacing + docs (development.md, CLAUDE.md) + STATE |
summarizeCourses-enhetstest (one-liner «N nye/M endrede kurs i dekkede produkter»). docs/development.md beskriver C3-workflow. CLAUDE.md kommando-/hook-tabell oppdatert. |
8. Besluttede valg (delegert til arkitekt, S31)
Operatøren kunne ikke ta disse selv og delegerte dem med mandatet «profesjonell plugin, til å stole på». Alle fire besluttet. (a) og (c) ble skjerpet utover opprinnelig anbefaling fordi kvalitetsmandatet krever det.
(a) HTTP-klient → fetch MED robusthetskontrakt. BESLUTTET. fetch (innebygd i Node 25, null ny avhengighet, riktig for bearer+JSON+POST). Skjerpelse: kontrakten i §6 er binding — eksplisitt response.ok-sjekk (fetch kaster ikke på 4xx/5xx), AbortSignal.timeout(), retry på 429/5xx som respekterer Retry-After. Uten denne kontrakten ville en 429 feiltolkes som tom respons. Forkastet: rått fetch uten kontrakt, og å speile node:https.get (dekker ikke POST/bearer).
(b) Baseline-kjøring emitterer ingen leads. BESLUTTET. Første kjøring etablerer kun registeret. Begrunnelse: en flom av N hundre «nye» kurs dag 1 lærer operatøren at verktøyet er støy, ikke signal — det motsatte av «til å stole på». Full backlog → fremtidig eksplisitt --backfill/audit-modus (roadmap, ikke MVP). Forkastet for MVP: behandle hele baseline som new-leads.
(c) removed = kun rapport-signal, MED korrekthets-pin. BESLUTTET. Informasjonslinje i rapporten, ikke ledger-lead. Skjerpelse (binding, §4.1/§4.2): removed beregnes KUN på full-enumereringskjøringer — aldri inkrementelt, der en updatedAt.gt-diff ville falskt flagge nesten hele registeret som removed. Dette er en tillitskritisk korrekthetsregel, ikke bare en preferanse. Forkastet: emittere removed som lead (et retirert kurs er ikke et tema å dekke); og naiv removed-diff på inkrementelle kjøringer (falsk-positiv flom).
(d) include_course_detection default false. BESLUTTET. Opt-in. Funksjonen krever Entra-creds nesten ingen har; default true ville gitt hver bruker et rødt/«skipped» steg. Forkastet: default true.
9. Verifiseringskilder
- Verifisert empirisk (S29):
c3-course-detection-spike.md— auth grønn, API-kontrakt, gotchas, live-probe. - Verifisert mot kode (S30, denne spec-en):
detection-schedule.mjs:22-41(DEFAULT_SCHEDULE_CONFIG, DETECTION_STEPS, coerce),discover-new-urls.mjs:12-14,100-108(to-stegs + read-only-invariant + report-form),registry-migrate.mjs:51-57(reservertcourse-felt allerede i registry-schema — C3-krok),domain-taxonomy.json(lag-0-konsolidering,category_skill-form),decisions-io.mjs(decisions/actions-kolleksjoner, dedup-policy A). - Standard (ikke prosjekt-spesifikt): Entra v2.0 client-credentials token-endepunkt
login.microsoftonline.com/<tenant>/oauth2/v2.0/token— konsistent med verifisert auth i spike (tenant-id i Keychain, scope.default). - Ikke verifisert (flagget): ekte produkt-slug for Azure AI Foundry (enumereres i C3.3).
- Løst ved design (S31, §8 (c)):
removed-deteksjonens fullstendighet ved server-sideupdatedAt.gt-filter. Forsvunne UIDs vises ikke i et filtrert svar →removedberegnes utelukkende på periodiske full-enumereringskjøringer (last_full_enum-kadens, §4.1/§4.2), aldri inkrementelt. Inkrementelle kjøringer setterremoved: [].