feat(ms-ai-architect): Sesjon 6 - lag 3 verifisering-INN (authority-binding)

Lag 3 etablerer per-fil autoritetsbinding som mater verify-out regel 3 (autoritets-mismatch — regresjonens rotårsak, inert til nå pga 0% dekning). Designvalg (operatør 2026-06-19): HEADER-AS-TRUTH — en fils **Source:**-header ER den utpekte autoriteten. Gjetter ALDRI fra siterte URLer (251/303 filer siterer 4-10 → gjetning ville fått regel 3 til å bomme + 303 operatør-beslutninger = primær-fallgruven).

- lib/authority.mjs (ren lib, SKRIVER ALDRI; kun read-only kb-headers+url-normalize): resolveAuthority (validert http(s); fri tekst avvist; ikke-MS-autoritet som EU-forordning tillatt), collectDeclaredSources (normalisert Set), markUrlAuthority, authorityCoverage. - build-registry: recomputer authority_source per URL-entry fra headere (ikke carry-over) + dekningslogg. - commands/kb-update.md §4 c1: authority_source=resolveAuthority(fil) → lag-5 c2.

TDD: 11 tester FØR kode inkl. import-invariant + ende-til-ende regel 3 (source_url ≠ header-autoritet → flagged). Tester: validate 239 · kb-update 122 (+11) · kb-eval 15 · kb-integrity 115/115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 22:58:10 +02:00
commit e42a84fb7b
4 changed files with 243 additions and 10 deletions

View file

@ -9,7 +9,7 @@ import { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeUrl, extractUrls } from './lib/url-normalize.mjs';
import { loadRegistry, saveRegistry } from './lib/registry-io.mjs';
import { parseSourceHeader } from './lib/kb-headers.mjs';
import { resolveAuthority, markUrlAuthority } from './lib/authority.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
@ -36,6 +36,7 @@ const existing = merge ? loadRegistry() : null;
const urlToFiles = new Map(); // normalizedUrl → Set<relativePath>
let totalFiles = 0;
let filesWithSourceHeader = 0; // coverage of the optional **Source:** authority header
const declaredSources = new Set(); // normalized authority URLs declared by files (lag 3)
const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory())
@ -51,10 +52,15 @@ for (const skill of skillDirs) {
const urls = extractUrls(content);
const relPath = relative(PLUGIN_ROOT, file);
// Read the optional **Source:**/**Primary source:** authority header.
// 0% coverage today; tracked here so we can populate authority_source as
// files adopt it (population semantics designed in the verification layer).
if (parseSourceHeader(content)) filesWithSourceHeader++;
// Lag 3 (header-as-truth): the **Source:** header is the file's declared
// authority. Collect declared sources (normalized) so each URL entry can be
// marked as a designated authority below.
const authority = resolveAuthority(content);
if (authority) {
filesWithSourceHeader++;
const n = normalizeUrl(authority);
if (n) declaredSources.add(n);
}
for (const url of urls) {
if (!urlToFiles.has(url)) urlToFiles.set(url, new Set());
@ -79,9 +85,10 @@ for (const [url, files] of urlToFiles) {
sitemap_lastmod: prev?.sitemap_lastmod || null,
reference_files: [...files].sort(),
status: prev?.status || 'unpolled',
// Reserved schema (lag 0): authority_source = declared verification source
// (**Source:** header, 0% coverage today); course = Learn course-track ref.
authority_source: prev?.authority_source ?? null,
// Lag 3: authority_source = this URL iff a file declares it as its **Source:**
// (a designated authority), else null. Recomputed from headers (truth), not
// carried over. course = Learn course-track ref (reserved).
authority_source: markUrlAuthority(url, declaredSources),
course: prev?.course ?? null,
};
}
@ -93,6 +100,8 @@ const multiRef = [...urlToFiles.values()].filter(s => s.size > 1).length;
console.log(`Registry built: ${urlToFiles.size} unique URLs from ${totalFiles} files`);
console.log(` URLs referenced by multiple files: ${multiRef}`);
console.log(` Files declaring **Source:** header: ${filesWithSourceHeader}/${totalFiles}`);
const authMarked = Object.values(registry.urls).filter(e => e.authority_source).length;
console.log(` URL entries marked as authority_source: ${authMarked}/${urlToFiles.size}`);
if (merge && existing?.urls) {
const newUrls = [...urlToFiles.keys()].filter(u => !existing.urls[u]).length;
console.log(` New URLs added (merge): ${newUrls}`);

View file

@ -0,0 +1,80 @@
// authority.mjs — Lag 3: VERIFISERING-INN. Establishes the per-file authority
// binding that feeds verify-out rule 3 (authority mismatch — the regression's
// root cause) and build-registry's per-URL authority_source field.
//
// Design (operatør-valg 2026-06-19): HEADER-AS-TRUTH. A file's **Source:** header
// IS the designated authority for that file's claims — full stop. We never GUESS
// an authority from a file's many cited URLs (251/303 files cite 410 URLs, so a
// derivation would be a guess, and a wrong authority makes rule 3 misfire). Coverage
// grows as files adopt the header (lag 4 writes it for every regenerated file).
//
// Pure lib (mirrors decisions-io / verify-out / transform): the only imports are
// read-only sibling parsers (kb-headers, url-normalize). It NEVER writes.
import { parseSourceHeader } from './kb-headers.mjs';
import { normalizeUrl } from './url-normalize.mjs';
const RE_HTTP_URL = /^https?:\/\/\S+$/i;
/**
* The designated authority for a file's claims = its declared **Source:** header,
* IF that value is a real http(s) URL. A free-text "source" is not an authority
* binding (verifiseringsplikt: no guessed authority). The URL is returned as
* declared (not normalized) it may legitimately be a non-Microsoft authority
* (e.g. an EU regulation for a governance file); verify-out normalizes when it
* compares. Returns null when absent or not a URL.
* @param {string} content full reference-file content (header scanned)
* @returns {string|null}
*/
export function resolveAuthority(content) {
const src = parseSourceHeader(content);
if (!src) return null;
return RE_HTTP_URL.test(src.trim()) ? src.trim() : null;
}
/**
* Collect the set of declared authority sources across many files, normalized to
* match registry keys. Only learn.microsoft.com authorities survive normalization
* (normalizeUrl returns null otherwise) which is correct, since the registry
* only tracks Microsoft Learn URLs. A non-MS authority is a valid per-file binding
* but is simply not a tracked registry entry to mark.
* @param {string[]} contents reference-file contents
* @returns {Set<string>} normalized declared-source URLs
*/
export function collectDeclaredSources(contents) {
const set = new Set();
for (const c of contents || []) {
const a = resolveAuthority(c);
if (!a) continue;
const n = normalizeUrl(a);
if (n) set.add(n);
}
return set;
}
/**
* The authority_source value for a registry URL entry: the URL itself when some
* file declares it as its **Source:** (i.e. this URL is a designated authority),
* else null. The registry is URL-keyed, so storing the URL is type-consistent with
* the change.authority_source field rule 3 consumes; null keeps the field honest
* where no file vouches for the URL.
* @param {string} normUrl a normalized registry URL key
* @param {Set<string>} declaredSet output of collectDeclaredSources
* @returns {string|null}
*/
export function markUrlAuthority(normUrl, declaredSet) {
return declaredSet && declaredSet.has(normUrl) ? normUrl : null;
}
/**
* Coverage metric for the criterion ("authority_source-dekning > 0%").
* @param {{urls: object}} registry
* @returns {{withAuthority: number, total: number, ratio: number}}
*/
export function authorityCoverage(registry) {
const urls = (registry && registry.urls) || {};
const entries = Object.values(urls);
const withAuthority = entries.filter((e) => e && e.authority_source).length;
const total = entries.length;
return { withAuthority, total, ratio: total ? withAuthority / total : 0 };
}