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>
252 lines
21 KiB
Markdown
252 lines
21 KiB
Markdown
# 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`](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 til `decisions.json` fra 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/products` finnes IKKE (404).
|
||
- **Paginering:** respons `{"value":[…], "nextLink":…}` — cursor via `nextLink`; `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)
|
||
1. **`updatedAt.gt` krever full ISO 8601 datetime.** `updatedAt.gt=2026-01-01T00:00:00Z` → virker. Dato-only `updatedAt.gt=2026-01-01` → **fail-closed (0 treff)**, selv om doc-eksempelet viser dato-only. Currency-cursoren må alltid serialiseres som full ISO med `Z`.
|
||
2. **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 distinct `products[].id` (siden `/api/v1/products` ikke finnes). `azure-openai` (34) bekreftet levende.
|
||
3. **Locale:** bruk `en-us` som primær (`nb-no` faller 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-14` importerer kun `loadDecisions, isDecided` — aldri `saveDecisions`. Håndhevet av `tests/kb-update/test-discover-invariant.test.mjs` (grepper import-linjene, `assert.doesNotMatch` på `saveDecisions`/`atomic-write`/`backup`).
|
||
- `run-detection.mjs` kjører kun `execFileSync('node', …)` på allow-listede steg — kan strukturelt aldri spawne `claude` (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).
|
||
|
||
```js
|
||
// 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).
|
||
|
||
```jsonc
|
||
{
|
||
"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 uten `updatedAt.gt` → etablerer baseline, setter `last_run = now` **og `last_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. **`removed` beregnes IKKE her** (et `updatedAt.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_enum` mangler eller er eldre enn 30 d): full paginering uten `updatedAt.gt` → diff gir new/updated **+ removed** (full API-sett vs. register). Setter `last_run = now` og `last_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) → lead `kind: "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)):** `removed` beregnes KUN på full-enumereringskjøringer (§4.1) — aldri på inkrementelle `updatedAt.gt`-kjøringer, der «ikke i svar» er sant for alt uendret og ville gi massiv falsk-positiv flom. Inkrementelle kjøringer setter `removed: []`.
|
||
|
||
### 4.3 `data/course-detection-report.json` — STEG 1-output (NY)
|
||
Analogt med `discovery-report.json`:
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
"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:
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```js
|
||
{ 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:
|
||
1. **`fetch` kaster ikke på HTTP 4xx/5xx** (kun på nettverksfeil) → wrapperen sjekker `response.ok` eksplisitt og behandler ikke-ok som feil.
|
||
2. **`fetch` har ingen default timeout** → hver request får `AbortSignal.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):
|
||
```js
|
||
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`](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` (reservert `course`-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-side `updatedAt.gt`-filter. Forsvunne UIDs vises ikke i et filtrert svar → `removed` beregnes utelukkende på periodiske full-enumereringskjøringer (`last_full_enum`-kadens, §4.1/§4.2), aldri inkrementelt. Inkrementelle kjøringer setter `removed: []`.
|