From d3ef7c6ed2160f17a0ae23c17ba6ebd9cf2b5d13 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 29 Sep 2026 18:31:43 +0000 Subject: [PATCH] Sync source trust policy and independence scoring --- src/db.ts | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 34 ++++++- src/mcp.ts | 48 +++++++++- src/web.ts | 85 +++++++++++++++++- 4 files changed, 407 insertions(+), 7 deletions(-) diff --git a/src/db.ts b/src/db.ts index f593696..427a59e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -221,6 +221,24 @@ export async function migrate() { payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + + CREATE TABLE IF NOT EXISTS source_trust_policy ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + trust_weight NUMERIC(4,3) NOT NULL CHECK (trust_weight >= 0 AND trust_weight <= 1), + independence_group TEXT NOT NULL, + source_class TEXT NOT NULL DEFAULT 'independent', + notes TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + created_by TEXT NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_source_trust_policy_enabled_weight + ON source_trust_policy(enabled, trust_weight DESC); `); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS baseline_complete BOOLEAN NOT NULL DEFAULT FALSE"); @@ -352,6 +370,235 @@ export async function seedSources(sources: SourceDefinition[]) { ); } + +function normaliseTrustDomain(value: string) { + const raw = String(value ?? "").trim().toLowerCase(); + if (!raw) throw new Error("Source domain is required"); + let hostname = raw; + try { + const url = new URL(raw.includes("://") ? raw : `https://${raw}`); + hostname = url.hostname.toLowerCase(); + } catch { + throw new Error("Invalid source domain or URL"); + } + hostname = hostname.replace(/^www\./, "").replace(/\.$/, ""); + if (!hostname || !hostname.includes(".")) throw new Error("Invalid source domain"); + return hostname; +} + +function defaultTrustWeight(sourceClass: string, authorityTier: number) { + const byClass: Record = { + official_primary: 1.0, + official_data: 0.98, + official_registry: 0.97, + official_notice: 0.95, + official_analysis: 0.93, + catalogue: 0.75 + }; + if (sourceClass in byClass) return byClass[sourceClass]; + if (authorityTier === 1) return 0.92; + if (authorityTier === 2) return 0.78; + return 0.65; +} + +export async function seedSourceTrustPolicy(sources: SourceDefinition[]) { + for (const source of sources) { + const domain = normaliseTrustDomain(source.url); + const weight = defaultTrustWeight(source.sourceClass, source.authorityTier); + await pool.query( + `INSERT INTO source_trust_policy( + id,domain,name,trust_weight,independence_group,source_class,notes, + enabled,source_id,created_by + ) + VALUES($1,$2,$3,$4,$5,$6,$7,TRUE,$8,'system-seed') + ON CONFLICT(domain) DO UPDATE SET + source_id=COALESCE(source_trust_policy.source_id,EXCLUDED.source_id), + name=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.name ELSE source_trust_policy.name END, + independence_group=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.independence_group ELSE source_trust_policy.independence_group END, + source_class=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.source_class ELSE source_trust_policy.source_class END, + updated_at=NOW()`, + [ + randomUUID(), + domain, + source.publisher || source.name, + weight, + source.independenceGroup || domain, + source.sourceClass, + "Seeded from configured ingestion source. Weight is an editorial prior, not a truth guarantee.", + source.id + ] + ); + } +} + +export async function listSourceTrustPolicy(args: { enabledOnly?: boolean } = {}) { + const result = await pool.query( + `SELECT * + FROM source_trust_policy + ${args.enabledOnly ? "WHERE enabled=TRUE" : ""} + ORDER BY trust_weight DESC, name ASC` + ); + return result.rows; +} + +export async function upsertSourceTrustEntry( + args: { + domain: string; + name: string; + trustWeight: number; + independenceGroup?: string; + sourceClass?: string; + notes?: string; + enabled?: boolean; + }, + actorLabel: string +) { + const domain = normaliseTrustDomain(args.domain); + const name = String(args.name ?? "").trim(); + const weight = Number(args.trustWeight); + const independenceGroup = String(args.independenceGroup ?? domain).trim().toLowerCase(); + const sourceClass = String(args.sourceClass ?? "independent").trim().toLowerCase(); + const notes = String(args.notes ?? "").trim(); + + if (!name) throw new Error("Source name is required"); + if (!Number.isFinite(weight) || weight < 0 || weight > 1) { + throw new Error("Trust weight must be between 0 and 1"); + } + if (!independenceGroup) throw new Error("Independence group is required"); + if (name.length > 180 || notes.length > 4000) throw new Error("Source trust entry is too long"); + + const existing = await pool.query( + "SELECT id FROM source_trust_policy WHERE domain=$1", + [domain] + ); + const id = existing.rows[0]?.id ?? randomUUID(); + + const result = await pool.query( + `INSERT INTO source_trust_policy( + id,domain,name,trust_weight,independence_group,source_class,notes, + enabled,created_by + ) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT(domain) DO UPDATE SET + name=EXCLUDED.name, + trust_weight=EXCLUDED.trust_weight, + independence_group=EXCLUDED.independence_group, + source_class=EXCLUDED.source_class, + notes=EXCLUDED.notes, + enabled=EXCLUDED.enabled, + created_by=EXCLUDED.created_by, + updated_at=NOW() + RETURNING *`, + [ + id,domain,name,weight,independenceGroup,sourceClass,notes, + args.enabled !== false,actorLabel + ] + ); + + await pool.query( + `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) + VALUES($1,'source_trust.updated','source_trust',$2,$3::jsonb)`, + [randomUUID(), result.rows[0].id, JSON.stringify({ + domain, trustWeight: weight, independenceGroup, actor: actorLabel + })] + ); + + return result.rows[0]; +} + +export async function evaluateSourceTrust( + inputs: Array<{ url?: string; domain?: string; sourceId?: string }> +) { + if (!Array.isArray(inputs) || !inputs.length) { + throw new Error("At least one source is required"); + } + + const policy = await listSourceTrustPolicy({ enabledOnly: true }); + const bySourceId = new Map( + policy.filter((p: any) => p.source_id).map((p: any) => [String(p.source_id), p]) + ); + + const matched = inputs.slice(0, 50).map((input) => { + let entry: any = null; + let domain: string | null = null; + + if (input.sourceId && bySourceId.has(String(input.sourceId))) { + entry = bySourceId.get(String(input.sourceId)); + domain = String(entry.domain); + } else { + const value = input.url || input.domain; + if (value) { + try { domain = normaliseTrustDomain(value); } catch { domain = null; } + } + if (domain) { + entry = policy + .filter((p: any) => domain === p.domain || domain!.endsWith(`.${p.domain}`)) + .sort((a: any, b: any) => String(b.domain).length - String(a.domain).length)[0] ?? null; + } + } + + return { + input, + matched: Boolean(entry), + domain, + policy_id: entry?.id ?? null, + name: entry?.name ?? null, + weight: entry ? Number(entry.trust_weight) : null, + independence_group: entry?.independence_group ?? null, + source_class: entry?.source_class ?? null + }; + }); + + const groups = new Map(); + for (const item of matched.filter((x) => x.matched)) { + const group = String(item.independence_group || item.domain); + const current = groups.get(group); + if (!current || Number(item.weight) > Number(current.weight)) groups.set(group, item); + } + + const independent = Array.from(groups.values()).sort( + (a, b) => Number(b.weight) - Number(a.weight) + ); + const strongest = independent.length ? Number(independent[0].weight) : 0; + const corroborationBonus = independent + .slice(1) + .reduce((sum, item) => sum + Number(item.weight) * 0.05, 0); + const effectiveScore = Math.min(1, strongest + Math.min(0.15, corroborationBonus)); + const independentCount = independent.length; + + let confidenceBand = "unrated"; + if (independentCount >= 2 && effectiveScore >= 0.9) confidenceBand = "strong"; + else if (independentCount >= 2 && effectiveScore >= 0.75) confidenceBand = "moderate"; + else if (independentCount === 1 && strongest >= 0.85) confidenceBand = "strong_single_source"; + else if (independentCount >= 1) confidenceBand = "limited"; + + return { + policy_version: "source-trust-v1", + methodology: { + description: "Use the strongest configured source from each independence group. Repeated sources from the same publisher/group do not add corroboration. Additional independent groups contribute a small capped corroboration bonus.", + strongest_source_weight: strongest, + corroboration_bonus: Number(Math.min(0.15, corroborationBonus).toFixed(3)), + maximum_independence_bonus: 0.15 + }, + effective_score: Number(effectiveScore.toFixed(3)), + confidence_band: confidenceBand, + independent_source_count: independentCount, + meets_independence_minimum: independentCount >= 2, + policy_coverage: Number((matched.filter((x) => x.matched).length / matched.length).toFixed(3)), + independent_groups: independent.map((x) => ({ + group: x.independence_group || x.domain, + representative: x.name, + weight: x.weight, + domain: x.domain + })), + sources: matched, + guidance: independentCount >= 2 + ? "Independent corroboration present. Evaluate whether the sources genuinely support the same claim and check for shared upstream evidence." + : "Independent corroboration is not yet present. Seek at least one source from a different independence group before treating the claim as robust." + }; +} + + export function contentHash(item: NormalizedItem) { return createHash("sha256") .update(JSON.stringify({ diff --git a/src/index.ts b/src/index.ts index f59db72..9cc2127 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,10 @@ import { migrate, pool, seedInitialStoryThread, - seedSources + seedSources, + seedSourceTrustPolicy, + listSourceTrustPolicy, + upsertSourceTrustEntry } from "./db.js"; import { beginWebLogin, @@ -25,7 +28,7 @@ import { import { handleMcp } from "./mcp.js"; import { SOURCES } from "./sources.js"; import { syncAll } from "./sync.js"; -import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory } from "./web.js"; +import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory, renderTrustPolicy } from "./web.js"; const app = express(); app.set("trust proxy", true); @@ -218,6 +221,32 @@ app.post("/opportunities/:id/status", requireWebAuth, async (req, res) => { return res.redirect(req.body.returnTo === "detail" ? `/opportunities/${encodeURIComponent(id)}` : "/opportunities"); }); +app.get("/trust", requireWebAuth, async (req, res) => { + const entries = await listSourceTrustPolicy(); + const user = (req as any).user ?? {}; + const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); + return res.type("html").send(renderTrustPolicy({ entries, identity })); +}); + +app.post("/trust", requireWebAuth, async (req, res) => { + const user = (req as any).user ?? {}; + const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); + try { + await upsertSourceTrustEntry({ + domain: String(req.body.domain ?? ""), + name: String(req.body.name ?? ""), + trustWeight: Number(req.body.trust_weight), + independenceGroup: String(req.body.independence_group ?? ""), + sourceClass: String(req.body.source_class ?? "other"), + notes: String(req.body.notes ?? ""), + enabled: String(req.body.enabled ?? "true") === "true" + }, `web:${identity}`); + return res.redirect("/trust"); + } catch (error) { + return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); + } +}); + app.get("/opportunities/:id", requireWebAuth, async (req, res) => { const opportunity = await getOpportunity(String(req.params.id)); if (!opportunity) return res.status(404).send("Opportunity not found"); @@ -278,6 +307,7 @@ app.get("/", requireWebAuth, async (req, res) => { async function start() { await migrate(); await seedSources(SOURCES); + await seedSourceTrustPolicy(SOURCES); await seedInitialStoryThread(); const port = Number(process.env.PORT ?? 3000); diff --git a/src/mcp.ts b/src/mcp.ts index fe0b931..211bb38 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -18,7 +18,9 @@ import { markOpportunity, postAgentMessage, searchEvidence, - sourceStatus + sourceStatus, + evaluateSourceTrust, + listSourceTrustPolicy } from "./db.js"; import { syncAll } from "./sync.js"; import type { SourceId } from "./types.js"; @@ -34,6 +36,36 @@ const tools = [ description: "Show sync health and counts for each source.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, + { + name: "source_trust_policy", + description: "Return the current editorial source-trust policy: domains, weights, source classes and independence groups. Weights are configurable priors, not guarantees that a claim is true.", + inputSchema: { type: "object", properties: {}, additionalProperties: false } + }, + { + name: "source_trust_evaluate", + description: "Evaluate a set of source URLs/domains/source IDs against the editorial trust policy. Sources in the same independence group are deduplicated; genuinely independent groups add a small corroboration bonus.", + inputSchema: { + type: "object", + properties: { + sources: { + type: "array", + minItems: 1, + maxItems: 50, + items: { + type: "object", + properties: { + url: { type: "string" }, + domain: { type: "string" }, + source_id: { type: "string" } + }, + additionalProperties: false + } + } + }, + required: ["sources"], + additionalProperties: false + } + }, { name: "sources_sync", description: "Run ingestion now for one source or all sources.", @@ -286,6 +318,14 @@ function actorFromRequest(req: Request) { async function callTool(name: string, args: any, actor: ReturnType) { if (name === "sources_list") return result(await listSources()); if (name === "sources_status") return result(await sourceStatus()); + if (name === "source_trust_policy") return result(await listSourceTrustPolicy({ enabledOnly: true })); + if (name === "source_trust_evaluate") return result(await evaluateSourceTrust( + (args.sources ?? []).map((item: any) => ({ + url: item.url, + domain: item.domain, + sourceId: item.source_id + })) + )); if (name === "sources_sync") return result(await syncAll(args?.source_id as SourceId | undefined)); if (name === "opportunities_list") return result(await listOpportunities(args ?? {})); if (name === "opportunity_get") return result(await getOpportunity(args.id)); @@ -378,6 +418,11 @@ export async function handleMcp(req: Request, res: Response) { uri: "media://sources", name: "Configured media intelligence sources", mimeType: "application/json" + }, + { + uri: "media://source-trust-policy", + name: "Editorial source trust policy", + mimeType: "application/json" } ] }; @@ -393,6 +438,7 @@ export async function handleMcp(req: Request, res: Response) { } else if (method === "resources/read") { const uri = String(body.params?.uri ?? ""); if (uri === "media://sources") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSources(), null, 2) }] }; + else if (uri === "media://source-trust-policy") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSourceTrustPolicy({ enabledOnly: true }), null, 2) }] }; else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] }; diff --git a/src/web.ts b/src/web.ts index 5af9f27..150ed21 100644 --- a/src/web.ts +++ b/src/web.ts @@ -56,14 +56,15 @@ function evidenceTone(kind: unknown) { function shell(args: { title: string; - active: "dashboard" | "stories" | "opportunities"; + active: "dashboard" | "stories" | "opportunities" | "trust"; identity: string; body: string; }) { const nav = [ ["dashboard", "/", "Desk"], ["stories", "/stories", "Story Threads"], - ["opportunities", "/opportunities", "Opportunities"] + ["opportunities", "/opportunities", "Opportunities"], + ["trust", "/trust", "Source Trust"] ] as const; return ` @@ -180,8 +181,17 @@ button,input,select{font:inherit} .empty{text-align:center;padding:30px;color:var(--muted)} .toast{position:fixed;right:18px;bottom:18px;background:#172033;color:#fff;border-radius:9px;padding:10px 13px;font-size:.84rem;box-shadow:var(--shadow);opacity:0;transform:translateY(8px);pointer-events:none;transition:.18s}.toast.show{opacity:1;transform:translateY(0)} .mobile-only{display:none} -@media(max-width:980px){.grid-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}.thread-hero{grid-template-columns:1fr}} -@media(max-width:700px){.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}} +.trust-form{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end} +.trust-form .wide{grid-column:1/-1} +.field label{display:block;font-size:.76rem;color:var(--muted);font-weight:700;margin-bottom:5px} +.field input,.field select,.field textarea{width:100%;border:1px solid #cfd6df;border-radius:8px;padding:9px 10px;background:#fff;color:var(--ink)} +.field textarea{min-height:72px;resize:vertical} +.trust-row{display:grid;grid-template-columns:minmax(180px,1.2fr) minmax(160px,1fr) 90px minmax(160px,1fr) auto;gap:12px;align-items:center;padding:13px 0} +.trust-row+.trust-row{border-top:1px solid #edf0f4} +.weight{font-weight:800;font-variant-numeric:tabular-nums} +.weightbar{height:6px;background:#eef1f5;border-radius:999px;overflow:hidden;margin-top:5px}.weightbar>span{display:block;height:100%;background:#2762d2;border-radius:999px} +@media(max-width:980px){.trust-form{grid-template-columns:1fr 1fr}.trust-row{grid-template-columns:1fr auto}.trust-row .optional-col{display:none}.grid-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}.thread-hero{grid-template-columns:1fr}} +@media(max-width:700px){.trust-form{grid-template-columns:1fr}.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}} @@ -384,3 +394,70 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
${args.messages.length?args.messages.map((m:AnyRow)=>`
${esc(m.title)}
${esc(m.body)}
${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}${esc(m.actor_name)} via ${esc(m.actor_client_id)}
${esc(nz(m.created_at))}
`).join(""):`
No unread Agent Activity.
`}
`; return shell({title:"Desk",active:"dashboard",identity:args.identity,body}); } + + +export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) { + const body=` +
+
Editorial policy

Source Trust

+

Configurable source priors for agent research. Weights describe editorial confidence in a source class/domain; they do not make a claim true. Independence groups stop multiple pages from the same publisher being counted as independent corroboration.

+
+
+ +
+

Add or update a source

Submitting an existing domain updates its policy entry.

+
+
+
+
+
+
+
+
+
+
+
+ +
+

Current policy

${args.entries.length} configured domains. Agent scoring deduplicates by independence group.

+
+ ${args.entries.map((e:AnyRow)=>`
+
${esc(e.name)}
${esc(e.domain)}
${badge(e.source_class,"neutral")}${e.enabled?badge("enabled","green"):badge("disabled","neutral")}
+
Independence group
${esc(e.independence_group)}
+
${Number(e.trust_weight).toFixed(2)}
+
${esc(e.notes||"—")}
+ +
`).join("")} +
+
+ + `; + return shell({title:"Source Trust",active:"trust",identity:args.identity,body}); +}