docs(ms-ai-architect): C3 §8 besluttet (delegert) — spec GODKJENT, +fetch-robusthetskontrakt +removed full-enum-pin [skip-docs]

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 12:42:22 +02:00
commit 215437cb5d

View file

@ -1,6 +1,6 @@
# 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: TIL GODKJENNING.** Ingen detektor-kode skrives før operatør har godkjent denne spec-en. Stier relative til plugin-rot._
_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._
---
@ -95,7 +95,8 @@ Detektorens private «known-UID»-register. Skrives av detektoren, lest ved nest
{
"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_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"
@ -110,13 +111,14 @@ Detektorens private «known-UID»-register. Skrives av detektoren, lest ved nest
}
```
- **Første kjøring** (ingen `last_run`): full paginering uten `updatedAt.gt` → etablerer baseline, setter `last_run = now`. Ingen leads emitteres på baseline-kjøringen (alt er «kjent» fra start). _Operatør-beslutning: se §8 åpne punkt (b)._
- **Senere kjøringer:** `updatedAt.gt=<last_run>` henter kun endrede; diff klassifiserer.
- **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»). _Se §8 åpne punkt (c)._
- **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`:
@ -193,7 +195,13 @@ Ny config-nøkkel `include_course_detection` i `DEFAULT_SCHEDULE_CONFIG` — **d
## 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) med en eksplisitt retry/timeout-wrapper som speiler `fetchWithRetry`-semantikken (3 forsøk, lineær backoff, **fail-closed** på vedvarende nettverksfeil — caster opp så `detect-courses` kan fail-softe til en `error`-rapport + exit 0). _Se §8 åpne punkt (a): node:https vs fetch._
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
@ -212,8 +220,8 @@ Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart k
| 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. **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 + load/save | `test-course-diff.test.mjs` grønn: new/updated/removed/no-op + tom-baseline. Round-trip persistens-test. Diff-modulen er **ren** (invariant-test: ingen write-util-import). |
| **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.1C3.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. |
@ -221,17 +229,17 @@ Hver fase = ÉN økt, TDD (Iron Law: failing test først), gated på kjørbart k
---
## 8. Åpne beslutninger (til operatør i spec-review)
## 8. Besluttede valg (delegert til arkitekt, S31)
Disse er gjort med en anbefaling i spec-en, men operatøren bør bekrefte:
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: native `fetch` vs `node:https`.** Anbefaling: `fetch` (innebygd i Node 25, null ny avhengighet, renere for bearer+JSON+POST). Avvik fra eksisterende `node:https`-mønster, men begrunnet. Alternativ: speile `sitemap-stream.mjs` nøyaktig.
**(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.** Anbefaling: første kjøring etablerer kun `course-registry.json` (alt «kjent»), ingen leads — ellers drukner operatøren i N hundre eksisterende kurs på dag 1. Alternativ: behandle alt på baseline som `new`-leads (full backlog).
**(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` (retirerte kurs) = kun rapport-signal, ikke ledger-lead.** Anbefaling: informasjonslinje i rapporten (et retirert kurs er ikke et tema å dekke). Alternativ: emittér som lead så operatøren kan vurdere å fjerne tilhørende KB-dekning.
**(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) Default for `include_course_detection`.** Anbefaling: `false` (opt-in, krever creds). Bekreft.
**(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`._
---
@ -240,4 +248,5 @@ Disse er gjort med en anbefaling i spec-en, men operatøren bør bekrefte:
- **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); `removed`-deteksjonens fullstendighet ved server-side `updatedAt.gt`-filter (forsvunne UIDs vises ikke i et filtrert svar — krever periodisk full enumerering for å fange retirering; håndteres i C3.2/C3.4-design).
- **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: []`.