Sync source trust policy and independence scoring

This commit is contained in:
Admin 2026-09-29 18:31:43 +00:00
parent e42c8f0caa
commit d3ef7c6ed2
4 changed files with 407 additions and 7 deletions

247
src/db.ts
View File

@ -221,6 +221,24 @@ export async function migrate() {
payload JSONB NOT NULL DEFAULT '{}'::jsonb, payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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"); 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<string, number> = {
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<string, any>();
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) { export function contentHash(item: NormalizedItem) {
return createHash("sha256") return createHash("sha256")
.update(JSON.stringify({ .update(JSON.stringify({

View File

@ -12,7 +12,10 @@ import {
migrate, migrate,
pool, pool,
seedInitialStoryThread, seedInitialStoryThread,
seedSources seedSources,
seedSourceTrustPolicy,
listSourceTrustPolicy,
upsertSourceTrustEntry
} from "./db.js"; } from "./db.js";
import { import {
beginWebLogin, beginWebLogin,
@ -25,7 +28,7 @@ import {
import { handleMcp } from "./mcp.js"; import { handleMcp } from "./mcp.js";
import { SOURCES } from "./sources.js"; import { SOURCES } from "./sources.js";
import { syncAll } from "./sync.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(); const app = express();
app.set("trust proxy", true); 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"); 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) => { app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
const opportunity = await getOpportunity(String(req.params.id)); const opportunity = await getOpportunity(String(req.params.id));
if (!opportunity) return res.status(404).send("Opportunity not found"); if (!opportunity) return res.status(404).send("Opportunity not found");
@ -278,6 +307,7 @@ app.get("/", requireWebAuth, async (req, res) => {
async function start() { async function start() {
await migrate(); await migrate();
await seedSources(SOURCES); await seedSources(SOURCES);
await seedSourceTrustPolicy(SOURCES);
await seedInitialStoryThread(); await seedInitialStoryThread();
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);

View File

@ -18,7 +18,9 @@ import {
markOpportunity, markOpportunity,
postAgentMessage, postAgentMessage,
searchEvidence, searchEvidence,
sourceStatus sourceStatus,
evaluateSourceTrust,
listSourceTrustPolicy
} from "./db.js"; } from "./db.js";
import { syncAll } from "./sync.js"; import { syncAll } from "./sync.js";
import type { SourceId } from "./types.js"; import type { SourceId } from "./types.js";
@ -34,6 +36,36 @@ const tools = [
description: "Show sync health and counts for each source.", description: "Show sync health and counts for each source.",
inputSchema: { type: "object", properties: {}, additionalProperties: false } 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", name: "sources_sync",
description: "Run ingestion now for one source or all sources.", 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<typeof actorFromRequest>) { async function callTool(name: string, args: any, actor: ReturnType<typeof actorFromRequest>) {
if (name === "sources_list") return result(await listSources()); if (name === "sources_list") return result(await listSources());
if (name === "sources_status") return result(await sourceStatus()); 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 === "sources_sync") return result(await syncAll(args?.source_id as SourceId | undefined));
if (name === "opportunities_list") return result(await listOpportunities(args ?? {})); if (name === "opportunities_list") return result(await listOpportunities(args ?? {}));
if (name === "opportunity_get") return result(await getOpportunity(args.id)); 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", uri: "media://sources",
name: "Configured media intelligence sources", name: "Configured media intelligence sources",
mimeType: "application/json" 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") { } else if (method === "resources/read") {
const uri = String(body.params?.uri ?? ""); const uri = String(body.params?.uri ?? "");
if (uri === "media://sources") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSources(), null, 2) }] }; 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://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://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) }] }; else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] };

View File

@ -56,14 +56,15 @@ function evidenceTone(kind: unknown) {
function shell(args: { function shell(args: {
title: string; title: string;
active: "dashboard" | "stories" | "opportunities"; active: "dashboard" | "stories" | "opportunities" | "trust";
identity: string; identity: string;
body: string; body: string;
}) { }) {
const nav = [ const nav = [
["dashboard", "/", "Desk"], ["dashboard", "/", "Desk"],
["stories", "/stories", "Story Threads"], ["stories", "/stories", "Story Threads"],
["opportunities", "/opportunities", "Opportunities"] ["opportunities", "/opportunities", "Opportunities"],
["trust", "/trust", "Source Trust"]
] as const; ] as const;
return `<!doctype html> return `<!doctype html>
@ -180,8 +181,17 @@ button,input,select{font:inherit}
.empty{text-align:center;padding:30px;color:var(--muted)} .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)} .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} .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}} .trust-form{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end}
@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 .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}}
</style> </style>
</head> </head>
<body> <body>
@ -384,3 +394,70 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div><form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form></div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`; <div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div><form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form></div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
return shell({title:"Desk",active:"dashboard",identity:args.identity,body}); return shell({title:"Desk",active:"dashboard",identity:args.identity,body});
} }
export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
const body=`
<div class="page-head">
<div><div class="eyebrow">Editorial policy</div><h1>Source Trust</h1>
<p>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.</p>
</div>
</div>
<div class="panel">
<div class="section-head"><div><h2>Add or update a source</h2><p>Submitting an existing domain updates its policy entry.</p></div></div>
<form class="trust-form" method="post" action="/trust">
<div class="field"><label>Domain</label><input id="trust-domain" name="domain" placeholder="example.org" required></div>
<div class="field"><label>Name</label><input id="trust-name" name="name" placeholder="Publisher / source name" required></div>
<div class="field"><label>Weight</label><input id="trust-weight" name="trust_weight" type="number" min="0" max="1" step="0.01" value="0.80" required></div>
<div class="field"><label>Independence group</label><input id="trust-group" name="independence_group" placeholder="publisher-family" required></div>
<div class="field"><label>Source class</label><select id="trust-class" name="source_class">
<option value="official_primary">official primary</option>
<option value="official_data">official data</option>
<option value="official_registry">official registry</option>
<option value="official_notice">official notice</option>
<option value="official_analysis">official analysis</option>
<option value="independent_research">independent research</option>
<option value="newsroom">newsroom</option>
<option value="specialist">specialist</option>
<option value="catalogue">catalogue</option>
<option value="other">other</option>
</select></div>
<div class="field wide"><label>Notes</label><textarea id="trust-notes" name="notes" placeholder="Why this source is useful, caveats, methodology notes…"></textarea></div>
<div class="field"><label>Enabled</label><select id="trust-enabled" name="enabled"><option value="true">enabled</option><option value="false">disabled</option></select></div>
<div class="actions"><button class="btn btn-primary" type="submit">Save source</button></div>
</form>
</div>
<section class="section">
<div class="section-head"><div><h2>Current policy</h2><p>${args.entries.length} configured domains. Agent scoring deduplicates by independence group.</p></div></div>
<div class="panel">
${args.entries.map((e:AnyRow)=>`<div class="trust-row">
<div><div class="row-title">${esc(e.name)}</div><div class="meta mono">${esc(e.domain)}</div><div class="row-meta">${badge(e.source_class,"neutral")}${e.enabled?badge("enabled","green"):badge("disabled","neutral")}</div></div>
<div class="optional-col"><div class="meta">Independence group</div><div>${esc(e.independence_group)}</div></div>
<div><div class="weight">${Number(e.trust_weight).toFixed(2)}</div><div class="weightbar"><span style="width:${Math.round(Number(e.trust_weight)*100)}%"></span></div></div>
<div class="optional-col meta">${esc(e.notes||"—")}</div>
<button class="btn btn-sm" type="button" data-edit-trust
data-domain="${esc(e.domain)}" data-name="${esc(e.name)}" data-weight="${esc(e.trust_weight)}"
data-group="${esc(e.independence_group)}" data-class="${esc(e.source_class)}"
data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit</button>
</div>`).join("")}
</div>
</section>
<script>
document.querySelectorAll('[data-edit-trust]').forEach(function(btn){
btn.addEventListener('click',function(){
document.getElementById('trust-domain').value=btn.dataset.domain||'';
document.getElementById('trust-name').value=btn.dataset.name||'';
document.getElementById('trust-weight').value=btn.dataset.weight||'0.8';
document.getElementById('trust-group').value=btn.dataset.group||'';
document.getElementById('trust-class').value=btn.dataset.class||'other';
document.getElementById('trust-notes').value=btn.dataset.notes||'';
document.getElementById('trust-enabled').value=btn.dataset.enabled||'true';
window.scrollTo({top:0,behavior:'smooth'});
});
});
</script>`;
return shell({title:"Source Trust",active:"trust",identity:args.identity,body});
}