ms-ai-architect/docs/c3-course-detection-plan.md
Kjell Tore Guttormsen 215437cb5d 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>
2026-06-23 12:42:22 +02:00

252 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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``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):**`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.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. |
| **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: []`.