From 19ccb63b3a05bc2aa73e1d6e3c78497ec4c8684a Mon Sep 17 00:00:00 2001 From: bitmaster162 <115934939+bitmaster162@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:19:27 +0700 Subject: [PATCH] feat: add agent consumer evidence contract v1 --- .../edge/src/agent-consumer-contract-v1.js | 246 ++++++++++++++++++ services/edge/src/shadow-trade-export.js | 5 +- .../test/agent-consumer-contract-v1.test.js | 177 +++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 services/edge/src/agent-consumer-contract-v1.js create mode 100644 services/edge/test/agent-consumer-contract-v1.test.js diff --git a/services/edge/src/agent-consumer-contract-v1.js b/services/edge/src/agent-consumer-contract-v1.js new file mode 100644 index 0000000..428f84d --- /dev/null +++ b/services/edge/src/agent-consumer-contract-v1.js @@ -0,0 +1,246 @@ +import { createHash } from "node:crypto"; + +export const AGENT_CONSUMER_CONTRACT_VERSION = "visionassist.agent_consumer_contract.v1"; +export const VISUAL_EVIDENCE_SCHEMA = "tradingos.visual_market_evidence.v1"; +export const VISUAL_EVIDENCE_SOURCE_SCHEMA = "visionassist.market_observation.v1"; + +const TOP_LEVEL_KEYS = Object.freeze([ + "schema", + "source_id", + "source_schema", + "source_sha256", + "image_sha256", + "captured_at", + "symbol", + "venue", + "timeframe", + "quality", + "visible_observations", + "detector_summary", + "counterevidence", + "uncertainties", + "alternative_explanations", + "compact_digest", + "safety", + "evidence_sha256", + "trade_case_ref" +]); +const EVIDENCE_BODY_KEYS = Object.freeze(TOP_LEVEL_KEYS.filter( + (key) => key !== "evidence_sha256" && key !== "trade_case_ref" +)); +const SAFETY_KEYS = Object.freeze([ + "mode", + "execution_authority", + "can_trade", + "capital_permission", + "orders_allowed", + "signals_allowed" +]); +const TRADE_CASE_REF_KEYS = Object.freeze(["source_id", "sha256", "schema"]); +const QUALITY_KEYS = Object.freeze(["status", "reasons", "abstention_reason"]); +const OBSERVATION_KEYS = Object.freeze(["id", "description", "evidence_refs"]); +const DETECTOR_KEYS = Object.freeze([ + "detector_type", + "status", + "orientation", + "confidence", + "evidence_refs", + "counterevidence_refs", + "invalidation_conditions" +]); +const DETECTOR_TYPES = new Set(["SFP", "CHOCH", "BOS", "SWEEP_RECLAIM"]); +const DETECTOR_STATUSES = new Set(["SUPPORTED", "CANDIDATE", "UNKNOWN", "REJECTED"]); +const DETECTOR_ORIENTATIONS = new Set(["UPSIDE", "DOWNSIDE", "NEUTRAL", "UNKNOWN"]); +const QUALITY_STATUSES = new Set(["PASS", "REVISE", "REJECT", "ABSTAIN"]); +const SHA256 = /^[a-f0-9]{64}$/; +const RFC3339_WITH_TZ = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-](\d{2}):(\d{2}))$/; + +export class AgentConsumerContractError extends Error { + constructor(code, message = code) { + super(message); + this.name = "AgentConsumerContractError"; + this.code = code; + } +} + +function requireCondition(condition, code, message = code) { + if (!condition) { + throw new AgentConsumerContractError(code, message); + } +} + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function requireExactKeys(value, expectedKeys, path) { + requireCondition(isObject(value), `${path}_not_object`, `${path} must be an object`); + const expected = new Set(expectedKeys); + for (const key of expectedKeys) { + requireCondition(Object.hasOwn(value, key), `${path}_${key}_missing`, `${path}.${key} is required`); + } + for (const key of Object.keys(value)) { + requireCondition(expected.has(key), `${path}_${key}_unexpected`, `${path}.${key} is not allowed`); + } +} + +function requireStringArray(value, path, { minItems = 0 } = {}) { + requireCondition(Array.isArray(value), `${path}_not_array`, `${path} must be an array`); + requireCondition(value.length >= minItems, `${path}_too_short`, `${path} requires at least ${minItems} item(s)`); + value.forEach((item, index) => { + requireCondition(nonEmpty(item), `${path}_${index}_empty`, `${path}[${index}] must be a non-empty string`); + }); +} + +function canonicalize(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +export function sha256CanonicalObject(value) { + return createHash("sha256").update(canonicalize(value), "utf8").digest("hex"); +} + +function evidenceBody(record) { + const body = {}; + for (const key of EVIDENCE_BODY_KEYS) { + body[key] = record[key]; + } + return body; +} + +function daysInMonth(year, month) { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +export function validateCapturedAt(value) { + requireCondition(nonEmpty(value), "captured_at_missing", "captured_at must be a non-empty string"); + const match = RFC3339_WITH_TZ.exec(value); + requireCondition(Boolean(match), "captured_at_not_rfc3339_tz", "captured_at must be strict RFC3339 with Z or numeric offset"); + + const [, y, mo, d, h, mi, s, , zone, offH, offM] = match; + const year = Number(y); + const month = Number(mo); + const day = Number(d); + const hour = Number(h); + const minute = Number(mi); + const second = Number(s); + + requireCondition(year >= 1 && year <= 9999, "captured_at_year_invalid"); + requireCondition(month >= 1 && month <= 12, "captured_at_month_invalid"); + requireCondition(day >= 1 && day <= daysInMonth(year, month), "captured_at_day_invalid"); + requireCondition(hour >= 0 && hour <= 23, "captured_at_hour_invalid"); + requireCondition(minute >= 0 && minute <= 59, "captured_at_minute_invalid"); + requireCondition(second >= 0 && second <= 59, "captured_at_second_invalid"); + if (zone !== "Z") { + requireCondition(Number(offH) <= 23, "captured_at_offset_hour_invalid"); + requireCondition(Number(offM) <= 59, "captured_at_offset_minute_invalid"); + } + requireCondition(Number.isFinite(Date.parse(value)), "captured_at_unparseable"); + return value; +} + +function validateQuality(quality) { + requireExactKeys(quality, QUALITY_KEYS, "quality"); + requireCondition(QUALITY_STATUSES.has(quality.status), "quality_status_invalid"); + requireStringArray(quality.reasons, "quality_reasons"); + requireCondition( + quality.abstention_reason === null || nonEmpty(quality.abstention_reason), + "quality_abstention_reason_invalid" + ); +} + +function validateVisibleObservations(items) { + requireCondition(Array.isArray(items) && items.length > 0, "visible_observations_missing"); + items.forEach((item, index) => { + requireExactKeys(item, OBSERVATION_KEYS, `visible_observations_${index}`); + requireCondition(nonEmpty(item.id), `visible_observations_${index}_id_invalid`); + requireCondition(nonEmpty(item.description), `visible_observations_${index}_description_invalid`); + requireStringArray(item.evidence_refs, `visible_observations_${index}_evidence_refs`, { minItems: 1 }); + }); +} + +function validateDetectorSummary(items) { + requireCondition(Array.isArray(items) && items.length === DETECTOR_TYPES.size, "detector_summary_count_invalid"); + const seen = new Set(); + items.forEach((item, index) => { + requireExactKeys(item, DETECTOR_KEYS, `detector_summary_${index}`); + requireCondition(DETECTOR_TYPES.has(item.detector_type), `detector_summary_${index}_type_invalid`); + requireCondition(!seen.has(item.detector_type), `detector_summary_${index}_type_duplicate`); + seen.add(item.detector_type); + requireCondition(DETECTOR_STATUSES.has(item.status), `detector_summary_${index}_status_invalid`); + requireCondition(DETECTOR_ORIENTATIONS.has(item.orientation), `detector_summary_${index}_orientation_invalid`); + requireCondition( + item.confidence === null || (typeof item.confidence === "number" && Number.isFinite(item.confidence) && item.confidence >= 0 && item.confidence <= 1), + `detector_summary_${index}_confidence_invalid` + ); + requireStringArray(item.evidence_refs, `detector_summary_${index}_evidence_refs`); + requireStringArray(item.counterevidence_refs, `detector_summary_${index}_counterevidence_refs`); + requireStringArray(item.invalidation_conditions, `detector_summary_${index}_invalidation_conditions`); + }); + for (const detectorType of DETECTOR_TYPES) { + requireCondition(seen.has(detectorType), `detector_summary_${detectorType}_missing`); + } +} + +function validateSafety(safety) { + requireExactKeys(safety, SAFETY_KEYS, "safety"); + requireCondition(safety.mode === "SHADOW", "unsafe_mode"); + requireCondition(safety.execution_authority === "NONE", "unsafe_execution_authority"); + requireCondition(safety.can_trade === false, "unsafe_can_trade"); + requireCondition(safety.capital_permission === "DENY", "unsafe_capital_permission"); + requireCondition(safety.orders_allowed === false, "unsafe_orders_allowed"); + requireCondition(safety.signals_allowed === false, "unsafe_signals_allowed"); +} + +export function validateAgentConsumerEvidence(record) { + requireExactKeys(record, TOP_LEVEL_KEYS, "record"); + requireCondition(record.schema === VISUAL_EVIDENCE_SCHEMA, "wrong_schema"); + requireCondition(record.source_schema === VISUAL_EVIDENCE_SOURCE_SCHEMA, "wrong_source_schema"); + requireCondition(nonEmpty(record.source_id), "source_id_missing"); + requireCondition(SHA256.test(record.source_sha256), "source_sha256_invalid"); + requireCondition(SHA256.test(record.image_sha256), "image_sha256_invalid"); + validateCapturedAt(record.captured_at); + for (const field of ["symbol", "venue", "timeframe", "compact_digest"]) { + requireCondition(nonEmpty(record[field]), `${field}_missing`); + } + + validateQuality(record.quality); + validateVisibleObservations(record.visible_observations); + validateDetectorSummary(record.detector_summary); + requireStringArray(record.counterevidence, "counterevidence"); + requireStringArray(record.uncertainties, "uncertainties", { minItems: 1 }); + requireStringArray(record.alternative_explanations, "alternative_explanations", { minItems: 1 }); + validateSafety(record.safety); + + requireCondition(SHA256.test(record.evidence_sha256), "evidence_sha256_invalid"); + const recomputed = sha256CanonicalObject(evidenceBody(record)); + requireCondition(recomputed === record.evidence_sha256, "evidence_sha256_mismatch"); + + requireExactKeys(record.trade_case_ref, TRADE_CASE_REF_KEYS, "trade_case_ref"); + requireCondition(record.trade_case_ref.source_id === record.source_id, "trade_case_ref_source_mismatch"); + requireCondition(record.trade_case_ref.sha256 === record.evidence_sha256, "trade_case_ref_sha_mismatch"); + requireCondition(record.trade_case_ref.schema === record.schema, "trade_case_ref_schema_mismatch"); + + return { + valid: true, + contract_version: AGENT_CONSUMER_CONTRACT_VERSION, + schema: record.schema, + source_id: record.source_id, + evidence_sha256: record.evidence_sha256, + captured_at: record.captured_at, + decision_authority: "NONE", + execution_authority: "NONE", + can_trade: false, + capital_permission: "DENY" + }; +} diff --git a/services/edge/src/shadow-trade-export.js b/services/edge/src/shadow-trade-export.js index ea27121..b1d2d75 100644 --- a/services/edge/src/shadow-trade-export.js +++ b/services/edge/src/shadow-trade-export.js @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { validateAgentConsumerEvidence } from "./agent-consumer-contract-v1.js"; import { validateMarketObservation } from "./market-observation-contract.js"; export const TRADINGOS_VISUAL_EVIDENCE_SCHEMA = "tradingos.visual_market_evidence.v1"; @@ -92,7 +93,7 @@ export function exportVisualMarketEvidence(record) { } }; const evidenceSha256 = sha256Object(body); - return { + const exported = { ...body, evidence_sha256: evidenceSha256, trade_case_ref: { @@ -101,4 +102,6 @@ export function exportVisualMarketEvidence(record) { schema: TRADINGOS_VISUAL_EVIDENCE_SCHEMA } }; + validateAgentConsumerEvidence(exported); + return exported; } diff --git a/services/edge/test/agent-consumer-contract-v1.test.js b/services/edge/test/agent-consumer-contract-v1.test.js new file mode 100644 index 0000000..abf79ab --- /dev/null +++ b/services/edge/test/agent-consumer-contract-v1.test.js @@ -0,0 +1,177 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + AgentConsumerContractError, + sha256CanonicalObject, + validateAgentConsumerEvidence +} from "../src/agent-consumer-contract-v1.js"; +import { adaptIntentToMarketObservation } from "../src/market-observation-contract.js"; +import { exportVisualMarketEvidence } from "../src/shadow-trade-export.js"; + +const safeIntent = { + schema_version: "visionassist.intent.v1", + module_identity: "VISUAL_SEMANTIC_COGNITION_LAYER", + source: { + source_id: "chart:consumer-001", + modality: "chart_image", + captured_at: "2026-08-25T15:00:00Z" + }, + observations: [ + { + id: "obs-1", + status: "OBSERVATION", + description: "Price tests a marked resistance region.", + evidence_refs: ["region:resistance"] + } + ], + intent_hypotheses: [ + { + id: "hyp-1", + status: "HYPOTHESIS", + description: "Breakout continuation.", + confidence: 0.6, + evidence_refs: ["region:resistance"], + counterevidence_refs: [], + invalidation_conditions: ["Price closes back below resistance."] + }, + { + id: "hyp-2", + status: "HYPOTHESIS", + description: "Failed breakout.", + confidence: 0.4, + evidence_refs: ["region:resistance"], + counterevidence_refs: [], + invalidation_conditions: ["Price accepts above resistance."] + } + ], + human_context: { + present: false, + operator_goal: null, + operator_prior: null, + operator_confidence: null, + notes_sha256: null + }, + fusion_status: "AI_ONLY_INCOMPLETE", + uncertainties: ["Lower timeframe confirmation is not visible."], + alternative_explanations: ["The marked region may be stale."], + decision_status: "DIAGNOSTIC_ONLY", + action_code: "NO_ACTION", + execution_permission: "HOLD", + capital_permission: "DENY", + can_trade: false +}; + +function exportedEvidence(capturedAt = "2026-08-25T15:00:00Z") { + const intent = structuredClone(safeIntent); + intent.source.captured_at = capturedAt; + const observation = adaptIntentToMarketObservation(intent, { + requestId: "consumer-contract-001", + imageSha256: "a".repeat(64), + marketContext: { symbol: "BTCUSDT", venue: "Binance", timeframe: "1h" } + }); + return exportVisualMarketEvidence(observation); +} + +function rehash(record) { + const copy = structuredClone(record); + const { evidence_sha256: _sha, trade_case_ref: _ref, ...body } = copy; + copy.evidence_sha256 = sha256CanonicalObject(body); + copy.trade_case_ref = { + source_id: copy.source_id, + sha256: copy.evidence_sha256, + schema: copy.schema + }; + return copy; +} + +function expectDeny(fn, code) { + assert.throws( + fn, + (error) => error instanceof AgentConsumerContractError && (!code || error.code === code) + ); +} + +test("current exporter emits evidence that passes the frozen consumer contract", () => { + const evidence = exportedEvidence(); + const receipt = validateAgentConsumerEvidence(evidence); + assert.equal(receipt.valid, true); + assert.equal(receipt.execution_authority, "NONE"); + assert.equal(receipt.can_trade, false); + assert.equal(receipt.capital_permission, "DENY"); +}); + +test("golden vector has stable deterministic evidence SHA-256 and exact trade_case_ref", () => { + const evidence = exportedEvidence(); + assert.equal(evidence.evidence_sha256, "4ae5446d6faa50760a04d14c665174ca9ce59e2ca91ed7d36fe8c36fba30486f"); + assert.equal(evidence.trade_case_ref.sha256, evidence.evidence_sha256); +}); + +test("one-field tamper with stale hash fails closed", () => { + const evidence = exportedEvidence(); + evidence.symbol = "ETHUSDT"; + expectDeny(() => validateAgentConsumerEvidence(evidence), "evidence_sha256_mismatch"); +}); + +test("schema, source schema, and trade-case ref mismatches fail closed", () => { + const wrongSchema = exportedEvidence(); + wrongSchema.schema = "tradingos.visual_market_evidence.v2"; + expectDeny(() => validateAgentConsumerEvidence(wrongSchema), "wrong_schema"); + + const wrongSource = exportedEvidence(); + wrongSource.source_schema = "visionassist.market_observation.v2"; + expectDeny(() => validateAgentConsumerEvidence(wrongSource), "wrong_source_schema"); + + const wrongRef = exportedEvidence(); + wrongRef.trade_case_ref.sha256 = "b".repeat(64); + expectDeny(() => validateAgentConsumerEvidence(wrongRef), "trade_case_ref_sha_mismatch"); +}); + +test("missing, timezone-less, malformed, and impossible captured_at fail closed", () => { + for (const capturedAt of [null, "2026-08-25T15:00:00", "not-a-time", "2026-02-30T15:00:00Z"]) { + const evidence = exportedEvidence(); + evidence.captured_at = capturedAt; + const rebound = capturedAt == null ? evidence : rehash(evidence); + expectDeny(() => validateAgentConsumerEvidence(rebound)); + } +}); + +test("Z and explicit-offset captured_at pass", () => { + assert.equal(validateAgentConsumerEvidence(exportedEvidence("2026-08-25T15:00:00Z")).valid, true); + assert.equal(validateAgentConsumerEvidence(exportedEvidence("2026-08-25T22:00:00+07:00")).valid, true); +}); + +test("wall-clock freshness is deliberately outside the deterministic hash contract", () => { + assert.equal(validateAgentConsumerEvidence(exportedEvidence("2000-01-01T00:00:00Z")).valid, true); +}); + +test("all authority widening attempts fail closed", () => { + const mutations = [ + (x) => { x.safety.can_trade = true; }, + (x) => { x.safety.execution_authority = "LIVE"; }, + (x) => { x.safety.capital_permission = "ALLOW"; }, + (x) => { x.safety.orders_allowed = true; }, + (x) => { x.safety.signals_allowed = true; } + ]; + for (const mutate of mutations) { + const evidence = exportedEvidence(); + mutate(evidence); + const rebound = rehash(evidence); + expectDeny(() => validateAgentConsumerEvidence(rebound)); + } +}); + +test("uncertainty, counterevidence, and invalidation semantics survive validation unchanged", () => { + const evidence = exportedEvidence(); + const before = structuredClone({ + uncertainties: evidence.uncertainties, + counterevidence: evidence.counterevidence, + invalidation: evidence.detector_summary.map((item) => item.invalidation_conditions) + }); + validateAgentConsumerEvidence(evidence); + assert.deepEqual({ + uncertainties: evidence.uncertainties, + counterevidence: evidence.counterevidence, + invalidation: evidence.detector_summary.map((item) => item.invalidation_conditions) + }, before); +});