diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2ea95d..664b89f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -13,6 +13,8 @@ on: - "wc/**" - "wc2026/**" - "scripts/validate-pack.mjs" + - "scripts/source-artifact.mjs" + - "scripts/source-artifact.test.mjs" - ".github/workflows/pages.yml" workflow_dispatch: @@ -33,6 +35,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" + - name: Test connector source artifact contract + run: node scripts/source-artifact.test.mjs - name: Validate all packs run: node scripts/validate-pack.mjs diff --git a/.github/workflows/source-artifacts.yml b/.github/workflows/source-artifacts.yml new file mode 100644 index 0000000..3935ef2 --- /dev/null +++ b/.github/workflows/source-artifacts.yml @@ -0,0 +1,32 @@ +name: source-artifacts + +on: + push: + paths: + - "packs/**" + - "scripts/add-match-day.mjs" + - "scripts/source-artifact.mjs" + - "scripts/source-artifact.test.mjs" + - "scripts/validate-source-artifacts.mjs" + - ".github/workflows/source-artifacts.yml" + pull_request: + paths: + - "packs/**" + - "scripts/add-match-day.mjs" + - "scripts/source-artifact.mjs" + - "scripts/source-artifact.test.mjs" + - "scripts/validate-source-artifacts.mjs" + - ".github/workflows/source-artifacts.yml" + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Test source artifact contract + run: node scripts/source-artifact.test.mjs + - name: Validate source captures in all packs + run: node scripts/validate-source-artifacts.mjs diff --git a/docs/DAILY-AGENT-PROMPT.md b/docs/DAILY-AGENT-PROMPT.md index 89aad35..a715eb6 100644 --- a/docs/DAILY-AGENT-PROMPT.md +++ b/docs/DAILY-AGENT-PROMPT.md @@ -9,6 +9,9 @@ or unsourced scoreline is a public credibility failure. > first — it explains why a mechanical "Scorers: …" feed makes the page *disprove* Causari, and > defines exactly what a real `whyItMatters` / `nextWatchpoints` / typed-link set must contain. > The prompt below now also runs `scripts/causal-quality.mjs`, the gate that FAILS templated data. +> +> **Connector evidence follows [`docs/SOURCE-ARTIFACT-CONTRACT.md`](./SOURCE-ARTIFACT-CONTRACT.md).** +> A truncated tool preview is routing context, not sufficient evidence for a public factual claim. --- @@ -24,6 +27,10 @@ or unsourced scoreline is a public credibility failure. other packs, or unrelated files. 4. **Follow the graph model** (see `docs/PACKS.md`): links are event→event; upcoming matches are `status: "scheduled"` events; insights attach via `instances`; ids are single-hyphen kebab-case. +5. **Never silently trust truncation.** When evidence came through a connector, add explicit + `capture.mode: "connector"` and `capture.truncated: true|false`. If truncated, retain a full + checksummed artifact reference and read it (or independently verify the authoritative URL) + before publishing. If the full evidence cannot be retrieved, skip the claim. --- @@ -37,13 +44,17 @@ Each run, do exactly this: 1. Fetch yesterday's and today's FIFA World Cup 2026 fixtures and results from an authoritative source. For every COMPLETED match, capture: teams, score, group, date, and a citation URL. If you cannot verify a result against a real source, skip it — never guess a score. + If a tool or connector returns a truncated preview, do not treat that preview as the full source: + retrieve the full artifact or verify the claim directly at the authoritative URL first. 2. Build a match-day input file `scripts/match-day.json` following `scripts/match-day.example.json`: - "results": each completed match as an event. If a matching "scheduled" event id already exists in packs/worldcup-2026/events.json, reuse that id so the script flips it to completed. Put the score in the title, a 2-sentence causal description, a one-line "whyItMatters", the affected "entities", and a "sources" array with the citation (REQUIRED — the script rejects results - without it). + without it). For connector-obtained evidence, include `capture` metadata that follows + docs/SOURCE-ARTIFACT-CONTRACT.md; truncated captures require a full artifact URI, SHA-256, + byte length, bounded preview, and retrieval hint. - "scheduled": the next fixtures these results set up, as upcoming events. - "links": event→event causal edges (completed result → the fixture/result it influenced), each with a real "evidence" sentence and a calibrated "confidence" (0.6–0.85 typical; reserve @@ -53,7 +64,7 @@ Each run, do exactly this: 3. Apply + validate: node scripts/add-match-day.mjs scripts/match-day.json - node scripts/validate-pack.mjs worldcup-2026 # structure + node scripts/validate-pack.mjs worldcup-2026 # structure + source artifact safety node scripts/causal-quality.mjs worldcup-2026 # semantic causal gate If any command exits non-zero, fix the input and retry. Do NOT proceed on failure. (add-match-day.mjs already runs the causal-quality gate in memory before writing.) @@ -75,6 +86,8 @@ sourced update beats a large speculative one. - `add-match-day.mjs` validates the merged pack **in memory and writes nothing on error** — a bad input can't corrupt the pack on disk. - The honesty gate (`sources` required for completed results) is enforced in code, not just prose. +- Connector evidence must declare whether it was truncated; truncated previews cannot pass without + a retained, checksummed full-artifact reference. - CI (`validate-packs`) re-checks on every push as a backstop, and the gated Pages deploy (`.github/workflows/pages.yml`) will **not republish** the live data if validation fails — so the public visual keeps serving the last-good pack even if a bad commit somehow lands. diff --git a/docs/LIVE-UPDATES.md b/docs/LIVE-UPDATES.md index 2bed031..ba4a05d 100644 --- a/docs/LIVE-UPDATES.md +++ b/docs/LIVE-UPDATES.md @@ -50,10 +50,13 @@ whole pack in memory, writing nothing if anything is broken**. The scheduled age 1. **Write the day's input** — copy [`scripts/match-day.example.json`](../scripts/match-day.example.json) to `scripts/match-day.json` and fill in the day's **real, sourced** results, next fixtures, and links. - Every completed result needs a `sources` citation (the script rejects results without one). + Every completed result needs a `sources` citation (the script rejects results without one). If the + citation was obtained through a connector, follow [SOURCE-ARTIFACT-CONTRACT.md](SOURCE-ARTIFACT-CONTRACT.md): + declare truncation explicitly and retain a checksummed full artifact when a response was truncated. 2. **Apply + validate** (writes only if clean): ```bash node scripts/add-match-day.mjs scripts/match-day.json + node scripts/source-artifact.test.mjs node scripts/validate-pack.mjs worldcup-2026 ``` If either exits non-zero, fix the input and retry — never commit a broken pack. @@ -85,6 +88,11 @@ A `completed` result is a factual claim. Every one must carry a source, e.g.: Until a result is sourced, keep the pack README's "illustrative / proof-of-concept" banner. Calibrated, sourced data is the whole brand promise — do not publish invented scorelines as fact. +A connector preview is not automatically the full source. When `capture.mode` is `connector`, +`capture.truncated` must be explicit. A truncated capture is valid only when the complete payload is +retained behind a checksummed artifact reference and the updater reads that artifact (or verifies the +authoritative URL) before publishing. + --- ## Graph rules recap (see docs/PACKS.md) diff --git a/docs/SOURCE-ARTIFACT-CONTRACT.md b/docs/SOURCE-ARTIFACT-CONTRACT.md new file mode 100644 index 0000000..c54e3e9 --- /dev/null +++ b/docs/SOURCE-ARTIFACT-CONTRACT.md @@ -0,0 +1,95 @@ +# Source Artifact Safety Contract + +Status: v1 +Scope: evidence obtained through tools/connectors before it becomes public Causari data. + +## Problem + +Connector and tool responses may be truncated to fit an agent context. A preview is useful for routing, but it is unsafe as the sole basis for a public factual claim because omitted rows or fields may change the conclusion. + +Causari therefore distinguishes: + +```text +direct, independently retrievable citation +vs. +connector capture with explicit completeness metadata +``` + +The pack remains deterministic. This contract does not add another agent loop; it only controls what evidence an updater may trust. + +## Rules + +1. A normal authoritative URL may remain a legacy source object with `type`, `citation`, and `url`. +2. Evidence obtained through a connector should include `capture.mode: "connector"`. +3. Connector evidence must state `truncated: true|false`. Missing means unknown and fails validation. +4. A truncated response must retain a full artifact with: + - stable URI or content location; + - SHA-256 checksum; + - full payload byte length; + - bounded preview; + - retrieval instructions. +5. An agent must read the full artifact or independently verify the claim at the authoritative URL before publishing. +6. The artifact itself should not be copied into `events.json`; the pack stores only provenance metadata. +7. Secrets, access tokens, cookies, and private headers must never be retained in previews or artifacts. + +## Source examples + +### Direct source + +```json +{ + "type": "official", + "citation": "FIFA — World Cup 2026 match centre", + "url": "https://www.fifa.com/..." +} +``` + +### Complete connector response + +```json +{ + "type": "official", + "citation": "FIFA — World Cup 2026 match centre", + "url": "https://www.fifa.com/...", + "capture": { + "mode": "connector", + "truncated": false, + "capturedAt": "2026-07-15T01:05:00Z" + } +} +``` + +### Truncated connector response with retained artifact + +```json +{ + "type": "official", + "citation": "FIFA — World Cup 2026 match centre", + "url": "https://www.fifa.com/...", + "capture": { + "mode": "connector", + "truncated": true, + "capturedAt": "2026-07-15T01:05:00Z", + "artifact": { + "uri": "artifact://connector/fifa-2026-07-15.json", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "byteLength": 120345, + "preview": "{\"matches\":[...]}", + "retrievalHint": "Read the stored JSON artifact before verifying scoreline claims." + } + } +} +``` + +## Validation + +`validate-pack.mjs` calls `validateSourceCapture` for every event source. Existing direct citations remain backward compatible. Only a source that opts into connector capture is subject to the new completeness rules. + +Run: + +```bash +node scripts/source-artifact.test.mjs +node scripts/validate-pack.mjs +``` + +This contract protects provenance completeness. It does not prove that a source is authoritative or that the causal interpretation is correct; the honesty and causal-quality gates still apply. diff --git a/scripts/add-match-day.mjs b/scripts/add-match-day.mjs index d74ffc2..33b1894 100644 --- a/scripts/add-match-day.mjs +++ b/scripts/add-match-day.mjs @@ -15,6 +15,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validatePackData } from './validate-pack.mjs'; +import { validateEventSources } from './validate-source-artifacts.mjs'; import { assessCausalQuality } from './causal-quality.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -106,7 +107,17 @@ if (errors.length > 0) { for (const e of errors) console.error(` - ${e}`); process.exit(1); } -// (b) causal-quality gate — the layer must be real, not templated. Catches a + +// (b) source-artifact validation — connector truncation must be explicit and +// any truncated preview must retain a checksummed full-payload reference. +const sourceErrors = validateEventSources(merged.events, packId); +if (sourceErrors.length > 0) { + console.error(`✗ ${sourceErrors.length} source artifact error(s) — nothing written:`); + for (const e of sourceErrors) console.error(` - ${e}`); + process.exit(1); +} + +// (c) causal-quality gate — the layer must be real, not templated. Catches a // "Scorers: …" whyItMatters, a single-relationship graph, a backwards scoreline, // a scheduled event carrying a result, an unresolved round/team, etc. This is // what keeps the daily editorial honest even when structure is fine. diff --git a/scripts/source-artifact.mjs b/scripts/source-artifact.mjs new file mode 100644 index 0000000..1c98d47 --- /dev/null +++ b/scripts/source-artifact.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +const SHA256 = /^[a-f0-9]{64}$/i; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isNonNegativeInteger(value) { + return Number.isInteger(value) && value >= 0; +} + +function validIsoDate(value) { + return isNonEmptyString(value) && !Number.isNaN(Date.parse(value)); +} + +function validateArtifact(artifact, label) { + const errors = []; + + if (!isObject(artifact)) { + return [`${label}.artifact must be an object when connector output is truncated`]; + } + if (!isNonEmptyString(artifact.uri)) { + errors.push(`${label}.artifact.uri must point to the full stored payload`); + } + if (!isNonEmptyString(artifact.sha256) || !SHA256.test(artifact.sha256)) { + errors.push(`${label}.artifact.sha256 must be a 64-character SHA-256 hex digest`); + } + if (!isNonNegativeInteger(artifact.byteLength)) { + errors.push(`${label}.artifact.byteLength must be a non-negative integer`); + } + if (!isNonEmptyString(artifact.preview)) { + errors.push(`${label}.artifact.preview must be a non-empty bounded preview`); + } + if (!isNonEmptyString(artifact.retrievalHint)) { + errors.push(`${label}.artifact.retrievalHint must explain how to read the full payload`); + } + + return errors; +} + +/** + * Validate optional evidence-capture metadata attached to an event source. + * + * Legacy/direct URL citations do not need `capture`. Once evidence is obtained + * through a connector, `capture.mode = "connector"` makes truncation explicit. + * A truncated connector response is invalid unless the full payload is stored + * and addressable by a checksummed artifact reference. + */ +export function validateSourceCapture(source, label = 'source') { + const errors = []; + + if (!isObject(source)) { + return [`${label} must be an object`]; + } + + const capture = source.capture; + if (capture === undefined) return errors; + if (!isObject(capture)) return [`${label}.capture must be an object`]; + + if (!['direct', 'connector'].includes(capture.mode)) { + errors.push(`${label}.capture.mode must be "direct" or "connector"`); + return errors; + } + + if (capture.capturedAt !== undefined && !validIsoDate(capture.capturedAt)) { + errors.push(`${label}.capture.capturedAt must be an ISO date-time`); + } + + if (capture.mode === 'direct') { + if (capture.truncated === true) { + errors.push(`${label}.capture: direct evidence cannot claim a truncated connector payload`); + } + return errors; + } + + if (typeof capture.truncated !== 'boolean') { + errors.push(`${label}.capture.truncated must be an explicit boolean for connector evidence`); + return errors; + } + + if (capture.truncated === true) { + errors.push(...validateArtifact(capture.artifact, label)); + } else if (capture.artifact !== undefined) { + errors.push(...validateArtifact(capture.artifact, label)); + } + + return errors; +} diff --git a/scripts/source-artifact.test.mjs b/scripts/source-artifact.test.mjs new file mode 100644 index 0000000..dca3cc2 --- /dev/null +++ b/scripts/source-artifact.test.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import { validateSourceCapture } from './source-artifact.mjs'; +import { validateEventSources } from './validate-source-artifacts.mjs'; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const legacy = validateSourceCapture({ + type: 'official', + citation: 'FIFA match centre', + url: 'https://example.com/match', +}); +assert(legacy.length === 0, `legacy direct citation should remain valid: ${legacy.join('; ')}`); + +const missingFlag = validateSourceCapture({ + capture: { mode: 'connector', capturedAt: '2026-07-15T08:00:00Z' }, +}); +assert( + missingFlag.some((error) => error.includes('truncated')), + 'connector capture must declare truncation explicitly', +); + +const missingArtifact = validateSourceCapture({ + capture: { + mode: 'connector', + truncated: true, + capturedAt: '2026-07-15T08:00:00Z', + }, +}); +assert( + missingArtifact.some((error) => error.includes('artifact')), + 'truncated connector capture must retain a full artifact', +); + +const completeConnector = validateSourceCapture({ + capture: { + mode: 'connector', + truncated: false, + capturedAt: '2026-07-15T08:00:00Z', + }, +}); +assert(completeConnector.length === 0, completeConnector.join('; ')); + +const retainedArtifact = validateSourceCapture({ + capture: { + mode: 'connector', + truncated: true, + capturedAt: '2026-07-15T08:00:00Z', + artifact: { + uri: 'artifact://connector/fifa-match-centre-2026-07-15.json', + sha256: 'a'.repeat(64), + byteLength: 120345, + preview: '{"matches":[...]}', + retrievalHint: 'Read the stored JSON artifact before accepting any scoreline claim.', + }, + }, +}); +assert(retainedArtifact.length === 0, retainedArtifact.join('; ')); + +const packErrors = validateEventSources([ + { + id: 'safe-event', + sources: [{ + type: 'official', + citation: 'FIFA match centre', + url: 'https://example.com/match', + capture: { mode: 'connector', truncated: false }, + }], + }, + { + id: 'unsafe-event', + sources: [{ + type: 'official', + citation: 'Preview only', + capture: { mode: 'connector', truncated: true }, + }], + }, +], 'fixture'); +assert( + packErrors.some((error) => error.includes('unsafe-event') && error.includes('artifact')), + 'pack validator must reject a truncated capture without a full artifact', +); + +console.log('✓ source artifact safety tests passed'); diff --git a/scripts/validate-source-artifacts.mjs b/scripts/validate-source-artifacts.mjs new file mode 100644 index 0000000..fe55bde --- /dev/null +++ b/scripts/validate-source-artifacts.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { validateSourceCapture } from './source-artifact.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const PACKS_DIR = join(ROOT, 'packs'); + +export function validateEventSources(events, packId = 'pack') { + const errors = []; + if (!Array.isArray(events)) return [`[${packId}] events must be an array`]; + + for (const event of events) { + const eventId = event?.id || ''; + if (event?.sources === undefined) continue; + if (!Array.isArray(event.sources)) { + errors.push(`[${packId}] event ${eventId}: sources must be an array`); + continue; + } + + event.sources.forEach((source, index) => { + const sourceErrors = validateSourceCapture( + source, + `event ${eventId}.sources[${index}]`, + ); + for (const error of sourceErrors) errors.push(`[${packId}] ${error}`); + }); + } + + return errors; +} + +export function validatePackSourceArtifacts(packId) { + const eventsPath = join(PACKS_DIR, packId, 'events.json'); + try { + const events = JSON.parse(readFileSync(eventsPath, 'utf8')); + return validateEventSources(events, packId); + } catch (error) { + return [`[${packId}] cannot read events.json — ${error.message}`]; + } +} + +function main() { + const requestedPack = process.argv[2]; + if (!existsSync(PACKS_DIR)) { + console.error('No packs/ directory found.'); + process.exit(1); + } + + const packIds = requestedPack + ? [requestedPack] + : readdirSync(PACKS_DIR).filter((entry) => statSync(join(PACKS_DIR, entry)).isDirectory()); + + const errors = []; + console.log(`Validating source artifacts for ${packIds.length} pack(s):`); + for (const packId of packIds) { + const packErrors = validatePackSourceArtifacts(packId); + if (packErrors.length === 0) console.log(` ✓ ${packId}`); + errors.push(...packErrors); + } + + if (errors.length > 0) { + console.error(`\n✗ ${errors.length} source artifact error(s):`); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); + } + + console.log('\n✓ All source artifact captures are explicit and retrievable.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +}