From 309b6b882bdb32f567adc79a42582e85440ca501 Mon Sep 17 00:00:00 2001 From: ferueda Date: Tue, 28 Jul 2026 21:34:02 -0700 Subject: [PATCH] feat: freeze portable agent contracts --- .../fixtures/request.invalid-oversized.json | 56 ++ .../request.invalid-unknown-field.json | 57 ++ .../request.invalid-wrong-version.json | 56 ++ .../v1/fixtures/request.valid.json | 119 ++++ .../fixtures/response.invalid-malformed.json | 7 + .../response.invalid-unknown-field.json | 22 + .../v1/fixtures/response.valid.json | 53 ++ .../agent-execution/v1/request.schema.json | 262 ++++++++ .../agent-execution/v1/response.schema.json | 146 +++++ .../content-scout/v1/candidates.schema.json | 126 ++++ .../candidates.invalid-duplicate.json | 50 ++ ...ndidates.invalid-empty-suitable-angle.json | 27 + .../candidates.invalid-oversized.json | 16 + .../fixtures/candidates.invalid-too-many.json | 76 +++ .../candidates.invalid-unknown-field.json | 28 + .../v1/fixtures/candidates.valid-empty.json | 3 + .../v1/fixtures/candidates.valid.json | 27 + .../v1/fixtures/input.invalid-oversized.json | 17 + .../fixtures/input.invalid-unknown-field.json | 18 + .../fixtures/input.invalid-wrong-version.json | 17 + .../v1/fixtures/input.valid.json | 60 ++ .../agents/content-scout/v1/input.schema.json | 389 +++++++++++ contracts/embed.go | 10 + docs/roadmap.md | 4 +- .../application/agent_contract_schema_test.go | 222 +++++++ internal/application/agent_execution.go | 67 ++ internal/domain/agent.go | 614 ++++++++++++++++++ internal/domain/agent_test.go | 559 ++++++++++++++++ internal/domain/artifact.go | 455 +++++++++++++ 29 files changed, 3562 insertions(+), 1 deletion(-) create mode 100644 contracts/agent-execution/v1/fixtures/request.invalid-oversized.json create mode 100644 contracts/agent-execution/v1/fixtures/request.invalid-unknown-field.json create mode 100644 contracts/agent-execution/v1/fixtures/request.invalid-wrong-version.json create mode 100644 contracts/agent-execution/v1/fixtures/request.valid.json create mode 100644 contracts/agent-execution/v1/fixtures/response.invalid-malformed.json create mode 100644 contracts/agent-execution/v1/fixtures/response.invalid-unknown-field.json create mode 100644 contracts/agent-execution/v1/fixtures/response.valid.json create mode 100644 contracts/agent-execution/v1/request.schema.json create mode 100644 contracts/agent-execution/v1/response.schema.json create mode 100644 contracts/agents/content-scout/v1/candidates.schema.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.invalid-duplicate.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.invalid-empty-suitable-angle.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.invalid-oversized.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.invalid-too-many.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.invalid-unknown-field.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.valid-empty.json create mode 100644 contracts/agents/content-scout/v1/fixtures/candidates.valid.json create mode 100644 contracts/agents/content-scout/v1/fixtures/input.invalid-oversized.json create mode 100644 contracts/agents/content-scout/v1/fixtures/input.invalid-unknown-field.json create mode 100644 contracts/agents/content-scout/v1/fixtures/input.invalid-wrong-version.json create mode 100644 contracts/agents/content-scout/v1/fixtures/input.valid.json create mode 100644 contracts/agents/content-scout/v1/input.schema.json create mode 100644 contracts/embed.go create mode 100644 internal/application/agent_contract_schema_test.go create mode 100644 internal/application/agent_execution.go create mode 100644 internal/domain/agent.go create mode 100644 internal/domain/agent_test.go create mode 100644 internal/domain/artifact.go diff --git a/contracts/agent-execution/v1/fixtures/request.invalid-oversized.json b/contracts/agent-execution/v1/fixtures/request.invalid-oversized.json new file mode 100644 index 0000000..bf43603 --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/request.invalid-oversized.json @@ -0,0 +1,56 @@ +{ + "contractVersion": 1, + "jobId": "job_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "jobFingerprint": "1111111111111111111111111111111111111111111111111111111111111111", + "triggerEventId": "event_synthetic_analysis_completed", + "agent": { + "name": "content-scout", + "version": "content-scout-v1" + }, + "configuration": { + "promptVersion": "content-scout-prompt-v1", + "outputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "route": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "privacyPolicyVersion": "deterministic-privacy-v1", + "disclosurePolicyVersion": "content-disclosure-v1", + "safetyPolicyVersion": "content-safety-v1", + "retrievalPolicyVersion": "content-scout-knowledge-v1", + "digest": "d57f38624ab76712fe009cef87e89edbb01e8d50b09a4898276e7ed65d08a150", + "handlerConfigurationJson": {} + }, + "execution": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "agentDefinitionDigest": "4444444444444444444444444444444444444444444444444444444444444444", + "contractVersion": 1, + "recoveryPolicyVersion": "eve-0.27.8-default-recovery-v1" + }, + "input": { + "schemaName": "content-scout-input", + "schemaVersion": 1, + "schemaDigest": "7c431016c261bf3a3bc17aa12493f498e012d3168b70e5ec5f64ece10fcd900d", + "canonicalJson": {} + }, + "requiredOutputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "deadlineAt": "2026-07-28T18:00:00Z", + "authority": { + "allowRemote": true + } +} diff --git a/contracts/agent-execution/v1/fixtures/request.invalid-unknown-field.json b/contracts/agent-execution/v1/fixtures/request.invalid-unknown-field.json new file mode 100644 index 0000000..43f0d8c --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/request.invalid-unknown-field.json @@ -0,0 +1,57 @@ +{ + "contractVersion": 1, + "jobId": "job_synthetic_content_scout", + "jobFingerprint": "1111111111111111111111111111111111111111111111111111111111111111", + "triggerEventId": "event_synthetic_analysis_completed", + "agent": { + "name": "content-scout", + "version": "content-scout-v1" + }, + "configuration": { + "promptVersion": "content-scout-prompt-v1", + "outputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "route": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "privacyPolicyVersion": "deterministic-privacy-v1", + "disclosurePolicyVersion": "content-disclosure-v1", + "safetyPolicyVersion": "content-safety-v1", + "retrievalPolicyVersion": "content-scout-knowledge-v1", + "digest": "d57f38624ab76712fe009cef87e89edbb01e8d50b09a4898276e7ed65d08a150", + "handlerConfigurationJson": {} + }, + "execution": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "agentDefinitionDigest": "4444444444444444444444444444444444444444444444444444444444444444", + "contractVersion": 1, + "recoveryPolicyVersion": "eve-0.27.8-default-recovery-v1" + }, + "input": { + "schemaName": "content-scout-input", + "schemaVersion": 1, + "schemaDigest": "7c431016c261bf3a3bc17aa12493f498e012d3168b70e5ec5f64ece10fcd900d", + "canonicalJson": {} + }, + "requiredOutputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "deadlineAt": "2026-07-28T18:00:00Z", + "authority": { + "allowRemote": true + }, + "unexpected": "not allowed" +} diff --git a/contracts/agent-execution/v1/fixtures/request.invalid-wrong-version.json b/contracts/agent-execution/v1/fixtures/request.invalid-wrong-version.json new file mode 100644 index 0000000..02bdd14 --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/request.invalid-wrong-version.json @@ -0,0 +1,56 @@ +{ + "contractVersion": 2, + "jobId": "job_synthetic_content_scout", + "jobFingerprint": "1111111111111111111111111111111111111111111111111111111111111111", + "triggerEventId": "event_synthetic_analysis_completed", + "agent": { + "name": "content-scout", + "version": "content-scout-v1" + }, + "configuration": { + "promptVersion": "content-scout-prompt-v1", + "outputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "route": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "privacyPolicyVersion": "deterministic-privacy-v1", + "disclosurePolicyVersion": "content-disclosure-v1", + "safetyPolicyVersion": "content-safety-v1", + "retrievalPolicyVersion": "content-scout-knowledge-v1", + "digest": "d57f38624ab76712fe009cef87e89edbb01e8d50b09a4898276e7ed65d08a150", + "handlerConfigurationJson": {} + }, + "execution": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "agentDefinitionDigest": "4444444444444444444444444444444444444444444444444444444444444444", + "contractVersion": 1, + "recoveryPolicyVersion": "eve-0.27.8-default-recovery-v1" + }, + "input": { + "schemaName": "content-scout-input", + "schemaVersion": 1, + "schemaDigest": "7c431016c261bf3a3bc17aa12493f498e012d3168b70e5ec5f64ece10fcd900d", + "canonicalJson": {} + }, + "requiredOutputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "deadlineAt": "2026-07-28T18:00:00Z", + "authority": { + "allowRemote": true + } +} diff --git a/contracts/agent-execution/v1/fixtures/request.valid.json b/contracts/agent-execution/v1/fixtures/request.valid.json new file mode 100644 index 0000000..981bb03 --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/request.valid.json @@ -0,0 +1,119 @@ +{ + "contractVersion": 1, + "jobId": "job_synthetic_content_scout", + "jobFingerprint": "1111111111111111111111111111111111111111111111111111111111111111", + "triggerEventId": "event_synthetic_analysis_completed", + "agent": { + "name": "content-scout", + "version": "content-scout-v1" + }, + "configuration": { + "promptVersion": "content-scout-prompt-v1", + "outputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "route": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "privacyPolicyVersion": "deterministic-privacy-v1", + "disclosurePolicyVersion": "content-disclosure-v1", + "safetyPolicyVersion": "content-safety-v1", + "retrievalPolicyVersion": "content-scout-knowledge-v1", + "digest": "d57f38624ab76712fe009cef87e89edbb01e8d50b09a4898276e7ed65d08a150", + "handlerConfigurationJson": { + "approvedPublicTerms": [ + "Go" + ] + } + }, + "execution": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "agentDefinitionDigest": "4444444444444444444444444444444444444444444444444444444444444444", + "contractVersion": 1, + "recoveryPolicyVersion": "eve-0.27.8-default-recovery-v1" + }, + "input": { + "schemaName": "content-scout-input", + "schemaVersion": 1, + "schemaDigest": "7c431016c261bf3a3bc17aa12493f498e012d3168b70e5ec5f64ece10fcd900d", + "canonicalJson": { + "schemaVersion": 1, + "analysisRunId": "analysis_synthetic", + "coverage": { + "scope": "complete-retained-snapshot", + "selectedClaimCount": 1, + "selectedFactCount": 1 + }, + "claims": [ + { + "id": "claim_synthetic_lesson", + "type": "lesson", + "statement": "A small deterministic check can prevent a repeated integration mistake.", + "status": "inferred", + "confidence": 0.9, + "outcome": null, + "supportingFactIds": [ + "fact_synthetic_test" + ], + "supportingEvidenceIds": [ + "evidence_synthetic_test" + ], + "contradictingEvidenceIds": [] + } + ], + "facts": [ + { + "id": "fact_synthetic_test", + "kind": "test", + "outcome": "success", + "value": { + "tool": null, + "command": null, + "test": { + "framework": "go", + "command": { + "text": "go test ./...", + "emittedUtf8Bytes": 13, + "originalUtf8Bytes": 13, + "truncated": false + }, + "passed": 1, + "failed": 0, + "skipped": 0 + }, + "exitCode": null, + "error": null + }, + "evidenceIds": [ + "evidence_synthetic_test" + ] + } + ], + "omissions": { + "canonicalSegments": 0, + "omittedTextFactCount": 0, + "omittedTextOriginalUtf8Bytes": 0, + "unknownLineage": false + } + } + }, + "requiredOutputSchema": { + "name": "content-scout-candidates", + "version": 1, + "disposition": "strict", + "digest": "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" + }, + "deadlineAt": "2026-07-28T18:00:00Z", + "authority": { + "allowRemote": true + } +} diff --git a/contracts/agent-execution/v1/fixtures/response.invalid-malformed.json b/contracts/agent-execution/v1/fixtures/response.invalid-malformed.json new file mode 100644 index 0000000..4c7720c --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/response.invalid-malformed.json @@ -0,0 +1,7 @@ +{ + "contractVersion": 1, + "candidateJson": { + "ideas": [] + }, + "receipt": +} diff --git a/contracts/agent-execution/v1/fixtures/response.invalid-unknown-field.json b/contracts/agent-execution/v1/fixtures/response.invalid-unknown-field.json new file mode 100644 index 0000000..e4aecfb --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/response.invalid-unknown-field.json @@ -0,0 +1,22 @@ +{ + "contractVersion": 1, + "candidateJson": { + "ideas": [] + }, + "receipt": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "sessionId": "eve_session_synthetic", + "turnId": "eve_turn_synthetic", + "completedModelSteps": 1, + "requestedRoute": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "rawResponse": "not allowed" + } +} diff --git a/contracts/agent-execution/v1/fixtures/response.valid.json b/contracts/agent-execution/v1/fixtures/response.valid.json new file mode 100644 index 0000000..f246ef7 --- /dev/null +++ b/contracts/agent-execution/v1/fixtures/response.valid.json @@ -0,0 +1,53 @@ +{ + "contractVersion": 1, + "candidateJson": { + "ideas": [ + { + "concept": "Make agent boundaries testable", + "coreLesson": "A versioned JSON contract lets runtimes change without changing domain ownership.", + "audienceBenefit": "Developers can experiment with agent frameworks while keeping their core stable.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Many teams couple useful workflows to the first framework they try.", + "confidence": 0.91, + "shortPost": { + "suitable": true, + "angle": "State the boundary and the practical benefit." + }, + "thread": { + "suitable": true, + "angle": "Walk through event, job, execution, and admission." + }, + "article": { + "suitable": true, + "angle": "Compare portable and framework-owned agent architectures." + }, + "claimIds": [ + "claim_synthetic_lesson" + ] + } + ] + }, + "receipt": { + "executorKind": "eve", + "executorVersion": "0.27.8", + "sessionId": "eve_session_synthetic", + "turnId": "eve_turn_synthetic", + "completedModelSteps": 1, + "requestedRoute": { + "alias": "content-scout-v1", + "gateway": "vercel-ai-gateway", + "model": "openai/gpt-5.4-mini", + "provider": "azure", + "routeVersion": "content-scout-route-v1", + "serviceTier": "flex" + }, + "gatewayGenerationId": "generation_synthetic", + "usage": { + "inputTokens": 300, + "outputTokens": 140, + "totalTokens": 440 + }, + "costUsd": "0.00042", + "latencyMilliseconds": 850 + } +} diff --git a/contracts/agent-execution/v1/request.schema.json b/contracts/agent-execution/v1/request.schema.json new file mode 100644 index 0000000..bf7f931 --- /dev/null +++ b/contracts/agent-execution/v1/request.schema.json @@ -0,0 +1,262 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:noema:agent-execution:request:v1", + "title": "AgentExecutionRequestV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contractVersion", + "jobId", + "jobFingerprint", + "triggerEventId", + "agent", + "configuration", + "execution", + "input", + "requiredOutputSchema", + "deadlineAt", + "authority" + ], + "properties": { + "contractVersion": { + "const": 1 + }, + "jobId": { + "$ref": "#/$defs/id" + }, + "jobFingerprint": { + "$ref": "#/$defs/digest" + }, + "triggerEventId": { + "$ref": "#/$defs/id" + }, + "agent": { + "$ref": "#/$defs/agentIdentity" + }, + "configuration": { + "$ref": "#/$defs/configurationIdentity" + }, + "execution": { + "$ref": "#/$defs/executionIdentity" + }, + "input": { + "$ref": "#/$defs/input" + }, + "requiredOutputSchema": { + "$ref": "#/$defs/schemaIdentity" + }, + "deadlineAt": { + "type": "string", + "format": "date-time", + "minLength": 20, + "maxLength": 64 + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "allowRemote" + ], + "properties": { + "allowRemote": { + "const": true + } + } + } + }, + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "agentIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "$ref": "#/$defs/name" + }, + "version": { + "$ref": "#/$defs/version" + } + } + }, + "schemaIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version", + "disposition", + "digest" + ], + "properties": { + "name": { + "$ref": "#/$defs/name" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "disposition": { + "const": "strict" + }, + "digest": { + "$ref": "#/$defs/digest" + } + } + }, + "routeIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "alias", + "gateway", + "model", + "provider", + "routeVersion", + "serviceTier" + ], + "properties": { + "alias": { + "$ref": "#/$defs/name" + }, + "gateway": { + "$ref": "#/$defs/name" + }, + "model": { + "$ref": "#/$defs/name" + }, + "provider": { + "$ref": "#/$defs/name" + }, + "routeVersion": { + "$ref": "#/$defs/version" + }, + "serviceTier": { + "$ref": "#/$defs/name" + } + } + }, + "configurationIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "promptVersion", + "outputSchema", + "route", + "privacyPolicyVersion", + "disclosurePolicyVersion", + "safetyPolicyVersion", + "retrievalPolicyVersion", + "digest", + "handlerConfigurationJson" + ], + "properties": { + "promptVersion": { + "$ref": "#/$defs/version" + }, + "outputSchema": { + "$ref": "#/$defs/schemaIdentity" + }, + "route": { + "$ref": "#/$defs/routeIdentity" + }, + "privacyPolicyVersion": { + "$ref": "#/$defs/version" + }, + "disclosurePolicyVersion": { + "$ref": "#/$defs/version" + }, + "safetyPolicyVersion": { + "$ref": "#/$defs/version" + }, + "retrievalPolicyVersion": { + "$ref": "#/$defs/version" + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "handlerConfigurationJson": { + "type": "object", + "maxProperties": 32 + } + } + }, + "executionIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "executorKind", + "executorVersion", + "agentDefinitionDigest", + "contractVersion", + "recoveryPolicyVersion" + ], + "properties": { + "executorKind": { + "$ref": "#/$defs/name" + }, + "executorVersion": { + "$ref": "#/$defs/version" + }, + "agentDefinitionDigest": { + "$ref": "#/$defs/digest" + }, + "contractVersion": { + "const": 1 + }, + "recoveryPolicyVersion": { + "$ref": "#/$defs/version" + } + } + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaName", + "schemaVersion", + "schemaDigest", + "canonicalJson" + ], + "properties": { + "schemaName": { + "$ref": "#/$defs/name" + }, + "schemaVersion": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "schemaDigest": { + "$ref": "#/$defs/digest" + }, + "canonicalJson": { + "type": "object", + "maxProperties": 32 + } + } + } + } +} diff --git a/contracts/agent-execution/v1/response.schema.json b/contracts/agent-execution/v1/response.schema.json new file mode 100644 index 0000000..535eca8 --- /dev/null +++ b/contracts/agent-execution/v1/response.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:noema:agent-execution:response:v1", + "title": "AgentExecutionResponseV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contractVersion", + "candidateJson", + "receipt" + ], + "properties": { + "contractVersion": { + "const": 1 + }, + "candidateJson": { + "type": "object", + "maxProperties": 32 + }, + "receipt": { + "$ref": "#/$defs/receipt" + } + }, + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "routeIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "alias", + "gateway", + "model", + "provider", + "routeVersion", + "serviceTier" + ], + "properties": { + "alias": { + "$ref": "#/$defs/name" + }, + "gateway": { + "$ref": "#/$defs/name" + }, + "model": { + "$ref": "#/$defs/name" + }, + "provider": { + "$ref": "#/$defs/name" + }, + "routeVersion": { + "$ref": "#/$defs/version" + }, + "serviceTier": { + "$ref": "#/$defs/name" + } + } + }, + "usage": { + "type": "object", + "additionalProperties": false, + "required": [ + "inputTokens", + "outputTokens", + "totalTokens" + ], + "properties": { + "inputTokens": { + "type": "integer", + "minimum": 0 + }, + "outputTokens": { + "type": "integer", + "minimum": 0 + }, + "totalTokens": { + "type": "integer", + "minimum": 0 + } + } + }, + "receipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "executorKind", + "executorVersion", + "sessionId", + "turnId", + "completedModelSteps", + "requestedRoute" + ], + "properties": { + "executorKind": { + "$ref": "#/$defs/name" + }, + "executorVersion": { + "$ref": "#/$defs/version" + }, + "sessionId": { + "$ref": "#/$defs/id" + }, + "turnId": { + "$ref": "#/$defs/id" + }, + "completedModelSteps": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "requestedRoute": { + "$ref": "#/$defs/routeIdentity" + }, + "gatewayGenerationId": { + "$ref": "#/$defs/id" + }, + "usage": { + "$ref": "#/$defs/usage" + }, + "costUsd": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)(\\.[0-9]+)?$", + "maxLength": 64 + }, + "latencyMilliseconds": { + "type": "integer", + "minimum": 0, + "maximum": 86400000 + } + } + } + } +} diff --git a/contracts/agents/content-scout/v1/candidates.schema.json b/contracts/agents/content-scout/v1/candidates.schema.json new file mode 100644 index 0000000..7ee66a9 --- /dev/null +++ b/contracts/agents/content-scout/v1/candidates.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:noema:agent:content-scout:candidates:v1", + "title": "ContentScoutCandidatesV1", + "type": "object", + "additionalProperties": false, + "required": [ + "ideas" + ], + "properties": { + "ideas": { + "type": "array", + "maxItems": 5, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/candidate" + } + } + }, + "$defs": { + "boundedText": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "formatAngle": { + "type": "object", + "additionalProperties": false, + "required": [ + "suitable", + "angle" + ], + "properties": { + "suitable": { + "type": "boolean" + }, + "angle": { + "type": "string", + "maxLength": 1024 + } + }, + "allOf": [ + { + "if": { + "properties": { + "suitable": { + "const": true + } + }, + "required": [ + "suitable" + ] + }, + "then": { + "properties": { + "angle": { + "minLength": 1 + } + } + } + } + ] + }, + "candidate": { + "type": "object", + "additionalProperties": false, + "required": [ + "concept", + "coreLesson", + "audienceBenefit", + "hook", + "resonance", + "confidence", + "shortPost", + "thread", + "article", + "claimIds" + ], + "properties": { + "concept": { + "$ref": "#/$defs/boundedText" + }, + "coreLesson": { + "$ref": "#/$defs/boundedText" + }, + "audienceBenefit": { + "$ref": "#/$defs/boundedText" + }, + "hook": { + "$ref": "#/$defs/boundedText" + }, + "resonance": { + "$ref": "#/$defs/boundedText" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "shortPost": { + "$ref": "#/$defs/formatAngle" + }, + "thread": { + "$ref": "#/$defs/formatAngle" + }, + "article": { + "$ref": "#/$defs/formatAngle" + }, + "claimIds": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/id" + } + } + } + } + } +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.invalid-duplicate.json b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-duplicate.json new file mode 100644 index 0000000..29393e4 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-duplicate.json @@ -0,0 +1,50 @@ +{ + "ideas": [ + { + "concept": "Keep one portable boundary", + "coreLesson": "The runtime should not own domain meaning.", + "audienceBenefit": "Developers can replace execution frameworks safely.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Early prototypes often leak framework choices into durable data.", + "confidence": 0.9, + "shortPost": { + "suitable": true, + "angle": "State the boundary and its benefit." + }, + "thread": { + "suitable": false, + "angle": "" + }, + "article": { + "suitable": false, + "angle": "" + }, + "claimIds": [ + "claim_one" + ] + }, + { + "concept": "Keep one portable boundary", + "coreLesson": "The runtime should not own domain meaning.", + "audienceBenefit": "Developers can replace execution frameworks safely.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Early prototypes often leak framework choices into durable data.", + "confidence": 0.9, + "shortPost": { + "suitable": true, + "angle": "State the boundary and its benefit." + }, + "thread": { + "suitable": false, + "angle": "" + }, + "article": { + "suitable": false, + "angle": "" + }, + "claimIds": [ + "claim_one" + ] + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.invalid-empty-suitable-angle.json b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-empty-suitable-angle.json new file mode 100644 index 0000000..7ab6062 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-empty-suitable-angle.json @@ -0,0 +1,27 @@ +{ + "ideas": [ + { + "concept": "Keep one portable boundary", + "coreLesson": "The runtime should not own domain meaning.", + "audienceBenefit": "Developers can replace execution frameworks safely.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Early prototypes often leak framework choices into durable data.", + "confidence": 0.9, + "shortPost": { + "suitable": true, + "angle": "" + }, + "thread": { + "suitable": false, + "angle": "" + }, + "article": { + "suitable": false, + "angle": "" + }, + "claimIds": [ + "claim_one" + ] + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.invalid-oversized.json b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-oversized.json new file mode 100644 index 0000000..09e1233 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-oversized.json @@ -0,0 +1,16 @@ +{ + "ideas": [ + { + "concept": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Bound generated fields.", + "resonance": "Large model output should fail closed.", + "confidence": 0.5, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.invalid-too-many.json b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-too-many.json new file mode 100644 index 0000000..57dbb0f --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-too-many.json @@ -0,0 +1,76 @@ +{ + "ideas": [ + { + "concept": "Idea one", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "One.", + "resonance": "It is practical.", + "confidence": 0.9, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + }, + { + "concept": "Idea two", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Two.", + "resonance": "It is practical.", + "confidence": 0.8, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + }, + { + "concept": "Idea three", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Three.", + "resonance": "It is practical.", + "confidence": 0.7, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + }, + { + "concept": "Idea four", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Four.", + "resonance": "It is practical.", + "confidence": 0.6, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + }, + { + "concept": "Idea five", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Five.", + "resonance": "It is practical.", + "confidence": 0.5, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + }, + { + "concept": "Idea six", + "coreLesson": "Keep one boundary.", + "audienceBenefit": "Reduce coupling.", + "hook": "Six.", + "resonance": "It is practical.", + "confidence": 0.4, + "shortPost": {"suitable": true, "angle": "Short."}, + "thread": {"suitable": false, "angle": ""}, + "article": {"suitable": false, "angle": ""}, + "claimIds": ["claim_one"] + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.invalid-unknown-field.json b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-unknown-field.json new file mode 100644 index 0000000..b5e1372 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.invalid-unknown-field.json @@ -0,0 +1,28 @@ +{ + "ideas": [ + { + "concept": "Make agent boundaries testable", + "coreLesson": "A versioned JSON contract keeps the domain independent.", + "audienceBenefit": "Developers can replace the execution runtime.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Framework coupling creates expensive rewrites.", + "confidence": 0.91, + "shortPost": { + "suitable": true, + "angle": "State the boundary." + }, + "thread": { + "suitable": false, + "angle": "" + }, + "article": { + "suitable": false, + "angle": "" + }, + "claimIds": [ + "claim_synthetic_lesson" + ], + "rank": 1 + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.valid-empty.json b/contracts/agents/content-scout/v1/fixtures/candidates.valid-empty.json new file mode 100644 index 0000000..8b98793 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.valid-empty.json @@ -0,0 +1,3 @@ +{ + "ideas": [] +} diff --git a/contracts/agents/content-scout/v1/fixtures/candidates.valid.json b/contracts/agents/content-scout/v1/fixtures/candidates.valid.json new file mode 100644 index 0000000..9ea19e4 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/candidates.valid.json @@ -0,0 +1,27 @@ +{ + "ideas": [ + { + "concept": "Make agent boundaries testable", + "coreLesson": "A versioned JSON contract lets runtimes change without changing domain ownership.", + "audienceBenefit": "Developers can experiment with agent frameworks while keeping their core stable.", + "hook": "Your agent framework should be replaceable.", + "resonance": "Many teams couple useful workflows to the first framework they try.", + "confidence": 0.91, + "shortPost": { + "suitable": true, + "angle": "State the boundary and the practical benefit." + }, + "thread": { + "suitable": true, + "angle": "Walk through event, job, execution, and admission." + }, + "article": { + "suitable": true, + "angle": "Compare portable and framework-owned agent architectures." + }, + "claimIds": [ + "claim_synthetic_lesson" + ] + } + ] +} diff --git a/contracts/agents/content-scout/v1/fixtures/input.invalid-oversized.json b/contracts/agents/content-scout/v1/fixtures/input.invalid-oversized.json new file mode 100644 index 0000000..07fa882 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/input.invalid-oversized.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "analysisRunId": "analysis_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "coverage": { + "scope": "complete-retained-snapshot", + "selectedClaimCount": 0, + "selectedFactCount": 0 + }, + "claims": [], + "facts": [], + "omissions": { + "canonicalSegments": 0, + "omittedTextFactCount": 0, + "omittedTextOriginalUtf8Bytes": 0, + "unknownLineage": false + } +} diff --git a/contracts/agents/content-scout/v1/fixtures/input.invalid-unknown-field.json b/contracts/agents/content-scout/v1/fixtures/input.invalid-unknown-field.json new file mode 100644 index 0000000..6116e88 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/input.invalid-unknown-field.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "analysisRunId": "analysis_synthetic", + "coverage": { + "scope": "complete-retained-snapshot", + "selectedClaimCount": 0, + "selectedFactCount": 0 + }, + "claims": [], + "facts": [], + "omissions": { + "canonicalSegments": 0, + "omittedTextFactCount": 0, + "omittedTextOriginalUtf8Bytes": 0, + "unknownLineage": false + }, + "transcript": "not allowed" +} diff --git a/contracts/agents/content-scout/v1/fixtures/input.invalid-wrong-version.json b/contracts/agents/content-scout/v1/fixtures/input.invalid-wrong-version.json new file mode 100644 index 0000000..fb8d399 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/input.invalid-wrong-version.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 2, + "analysisRunId": "analysis_synthetic", + "coverage": { + "scope": "complete-retained-snapshot", + "selectedClaimCount": 0, + "selectedFactCount": 0 + }, + "claims": [], + "facts": [], + "omissions": { + "canonicalSegments": 0, + "omittedTextFactCount": 0, + "omittedTextOriginalUtf8Bytes": 0, + "unknownLineage": false + } +} diff --git a/contracts/agents/content-scout/v1/fixtures/input.valid.json b/contracts/agents/content-scout/v1/fixtures/input.valid.json new file mode 100644 index 0000000..4b9a2e3 --- /dev/null +++ b/contracts/agents/content-scout/v1/fixtures/input.valid.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "analysisRunId": "analysis_synthetic", + "coverage": { + "scope": "complete-retained-snapshot", + "selectedClaimCount": 1, + "selectedFactCount": 1 + }, + "claims": [ + { + "id": "claim_synthetic_lesson", + "type": "lesson", + "statement": "A small deterministic check can prevent a repeated integration mistake.", + "status": "inferred", + "confidence": 0.9, + "outcome": null, + "supportingFactIds": [ + "fact_synthetic_test" + ], + "supportingEvidenceIds": [ + "evidence_synthetic_test" + ], + "contradictingEvidenceIds": [] + } + ], + "facts": [ + { + "id": "fact_synthetic_test", + "kind": "test", + "outcome": "success", + "value": { + "tool": null, + "command": null, + "test": { + "framework": "go", + "command": { + "text": "go test ./...", + "emittedUtf8Bytes": 13, + "originalUtf8Bytes": 13, + "truncated": false + }, + "passed": 1, + "failed": 0, + "skipped": 0 + }, + "exitCode": null, + "error": null + }, + "evidenceIds": [ + "evidence_synthetic_test" + ] + } + ], + "omissions": { + "canonicalSegments": 0, + "omittedTextFactCount": 0, + "omittedTextOriginalUtf8Bytes": 0, + "unknownLineage": false + } +} diff --git a/contracts/agents/content-scout/v1/input.schema.json b/contracts/agents/content-scout/v1/input.schema.json new file mode 100644 index 0000000..ec7e20c --- /dev/null +++ b/contracts/agents/content-scout/v1/input.schema.json @@ -0,0 +1,389 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:noema:agent:content-scout:input:v1", + "title": "ContentScoutInputV1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "analysisRunId", + "coverage", + "claims", + "facts", + "omissions" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "analysisRunId": { + "$ref": "#/$defs/id" + }, + "coverage": { + "$ref": "#/$defs/coverage" + }, + "claims": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/claim" + } + }, + "facts": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/fact" + } + }, + "omissions": { + "$ref": "#/$defs/omissions" + } + }, + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "idArray": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/id" + } + }, + "boundedText": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "selectedText": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "emittedUtf8Bytes", + "originalUtf8Bytes", + "truncated" + ], + "properties": { + "text": { + "$ref": "#/$defs/boundedText" + }, + "emittedUtf8Bytes": { + "type": "integer", + "minimum": 1 + }, + "originalUtf8Bytes": { + "type": "integer", + "minimum": 1 + }, + "truncated": { + "type": "boolean" + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "selectedClaimCount", + "selectedFactCount" + ], + "properties": { + "scope": { + "type": "string", + "enum": [ + "complete-retained-snapshot", + "partial" + ] + }, + "selectedClaimCount": { + "type": "integer", + "minimum": 0, + "maximum": 128 + }, + "selectedFactCount": { + "type": "integer", + "minimum": 0, + "maximum": 256 + } + } + }, + "claim": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "statement", + "status", + "confidence", + "outcome", + "supportingFactIds", + "supportingEvidenceIds", + "contradictingEvidenceIds" + ], + "properties": { + "id": { + "$ref": "#/$defs/id" + }, + "type": { + "type": "string", + "enum": [ + "problem", + "symptom", + "hypothesis", + "failed-attempt", + "root-cause", + "decision", + "solution", + "verification", + "lesson" + ] + }, + "statement": { + "$ref": "#/$defs/boundedText" + }, + "status": { + "type": "string", + "enum": [ + "observed", + "inferred", + "uncertain" + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "outcome": { + "type": [ + "string", + "null" + ], + "enum": [ + "success", + "failure", + "unknown", + null + ] + }, + "supportingFactIds": { + "$ref": "#/$defs/idArray" + }, + "supportingEvidenceIds": { + "$ref": "#/$defs/idArray" + }, + "contradictingEvidenceIds": { + "$ref": "#/$defs/idArray" + } + } + }, + "toolValue": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "name", + "namespace" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 512 + }, + "namespace": { + "type": [ + "string", + "null" + ], + "maxLength": 512 + } + } + }, + "testValue": { + "type": "object", + "additionalProperties": false, + "required": [ + "framework", + "command", + "passed", + "failed", + "skipped" + ], + "properties": { + "framework": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "command": { + "anyOf": [ + { + "$ref": "#/$defs/selectedText" + }, + { + "type": "null" + } + ] + }, + "passed": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "failed": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "skipped": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "factValue": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "command", + "test", + "exitCode", + "error" + ], + "properties": { + "tool": { + "anyOf": [ + { + "$ref": "#/$defs/toolValue" + }, + { + "type": "null" + } + ] + }, + "command": { + "anyOf": [ + { + "$ref": "#/$defs/selectedText" + }, + { + "type": "null" + } + ] + }, + "test": { + "anyOf": [ + { + "$ref": "#/$defs/testValue" + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/selectedText" + }, + { + "type": "null" + } + ] + } + } + }, + "fact": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "outcome", + "value", + "evidenceIds" + ], + "properties": { + "id": { + "$ref": "#/$defs/id" + }, + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "outcome": { + "type": "string", + "enum": [ + "not-applicable", + "success", + "failure", + "unknown" + ] + }, + "value": { + "$ref": "#/$defs/factValue" + }, + "evidenceIds": { + "$ref": "#/$defs/idArray" + } + } + }, + "omissions": { + "type": "object", + "additionalProperties": false, + "required": [ + "canonicalSegments", + "omittedTextFactCount", + "omittedTextOriginalUtf8Bytes", + "unknownLineage" + ], + "properties": { + "canonicalSegments": { + "type": "integer", + "minimum": 0 + }, + "omittedTextFactCount": { + "type": "integer", + "minimum": 0 + }, + "omittedTextOriginalUtf8Bytes": { + "type": "integer", + "minimum": 0 + }, + "unknownLineage": { + "type": "boolean" + } + } + } + } +} diff --git a/contracts/embed.go b/contracts/embed.go new file mode 100644 index 0000000..885a14a --- /dev/null +++ b/contracts/embed.go @@ -0,0 +1,10 @@ +package contracts + +import "embed" + +// Files contains the language-neutral contracts used by Go and the standalone +// agent package. Keeping one checked-in copy prevents the runtimes from +// silently drifting. +// +//go:embed agent-execution/v1/*.schema.json agents/content-scout/v1/*.schema.json +var Files embed.FS diff --git a/docs/roadmap.md b/docs/roadmap.md index 2f40f61..313b693 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -347,7 +347,9 @@ second verifier or continue prompt tuning from this corpus alone. ## V0 Milestone 3: Content Scout -- Status: planned and approved +- Status: in progress — portable V1 execution and Content Scout candidate + contracts are implemented; clean persistence, matching, dispatch, and live + execution remain ### Goal diff --git a/internal/application/agent_contract_schema_test.go b/internal/application/agent_contract_schema_test.go new file mode 100644 index 0000000..eae53a8 --- /dev/null +++ b/internal/application/agent_contract_schema_test.go @@ -0,0 +1,222 @@ +package application + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ferueda/noema/internal/domain" +) + +const maxAgentContractDocumentBytes = 512 * 1024 + +func TestAgentContractSchemasHaveStableStrictIdentities(t *testing.T) { + tests := []struct { + path string + id string + title string + digest string + }{ + {"agent-execution/v1/request.schema.json", "urn:noema:agent-execution:request:v1", "AgentExecutionRequestV1", AgentExecutionRequestSchemaDigest}, + {"agent-execution/v1/response.schema.json", "urn:noema:agent-execution:response:v1", "AgentExecutionResponseV1", AgentExecutionResponseSchemaDigest}, + {"agents/content-scout/v1/input.schema.json", "urn:noema:agent:content-scout:input:v1", "ContentScoutInputV1", ContentScoutInputSchemaDigest}, + {"agents/content-scout/v1/candidates.schema.json", "urn:noema:agent:content-scout:candidates:v1", "ContentScoutCandidatesV1", ContentScoutCandidatesSchemaDigest}, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + document := readContractFile(t, test.path) + if len(document) > maxAgentContractDocumentBytes { + t.Fatalf("schema bytes = %d, want at most %d", len(document), maxAgentContractDocumentBytes) + } + var compact bytes.Buffer + if err := json.Compact(&compact, document); err != nil { + t.Fatalf("compact schema: %v", err) + } + sum := sha256.Sum256(compact.Bytes()) + if digest := hex.EncodeToString(sum[:]); digest != test.digest { + t.Fatalf("schema digest = %s, want %s", digest, test.digest) + } + var identity struct { + Schema string `json:"$schema"` + ID string `json:"$id"` + Title string `json:"title"` + Type string `json:"type"` + } + if err := json.Unmarshal(document, &identity); err != nil { + t.Fatalf("decode schema identity: %v", err) + } + if identity.Schema != "https://json-schema.org/draft/2020-12/schema" || + identity.ID != test.id || identity.Title != test.title || identity.Type != "object" { + t.Fatalf("schema identity = %#v", identity) + } + var schema map[string]any + if err := json.Unmarshal(document, &schema); err != nil { + t.Fatalf("decode schema: %v", err) + } + assertFixedSchemaObjectsAreStrict(t, schema, "") + }) + } +} + +func TestContentScoutOutputSchemaLoadsFromFrozenContract(t *testing.T) { + schema, err := ContentScoutOutputSchema() + if err != nil { + t.Fatalf("load output schema: %v", err) + } + if schema.Identity.Name != ContentScoutCandidatesSchemaName || + schema.Identity.Version != domain.ContentIdeaSchemaVersion || + schema.Identity.Digest != ContentScoutCandidatesSchemaDigest || + schema.Identity.Disposition != domain.StructuredOutputDispositionStrict || + !json.Valid(schema.CanonicalJSON) { + t.Fatalf("output schema = %#v", schema) + } +} + +func TestAgentExecutionContractFixturesUseAuthoritativeGoTypes(t *testing.T) { + tests := []struct { + name string + path string + decode func([]byte) error + wantErr bool + }{ + {"valid request", "agent-execution/v1/fixtures/request.valid.json", decodeExecutionRequestFixture, false}, + {"request unknown field", "agent-execution/v1/fixtures/request.invalid-unknown-field.json", decodeExecutionRequestFixture, true}, + {"request wrong version", "agent-execution/v1/fixtures/request.invalid-wrong-version.json", decodeExecutionRequestFixture, true}, + {"request oversized value", "agent-execution/v1/fixtures/request.invalid-oversized.json", decodeExecutionRequestFixture, true}, + {"valid response", "agent-execution/v1/fixtures/response.valid.json", decodeExecutionResponseFixture, false}, + {"response unknown field", "agent-execution/v1/fixtures/response.invalid-unknown-field.json", decodeExecutionResponseFixture, true}, + {"response malformed", "agent-execution/v1/fixtures/response.invalid-malformed.json", decodeExecutionResponseFixture, true}, + {"valid input", "agents/content-scout/v1/fixtures/input.valid.json", decodeContentScoutInputFixture, false}, + {"input unknown field", "agents/content-scout/v1/fixtures/input.invalid-unknown-field.json", decodeContentScoutInputFixture, true}, + {"input wrong version", "agents/content-scout/v1/fixtures/input.invalid-wrong-version.json", decodeContentScoutInputFixture, true}, + {"input oversized value", "agents/content-scout/v1/fixtures/input.invalid-oversized.json", decodeContentScoutInputFixture, true}, + {"valid candidates", "agents/content-scout/v1/fixtures/candidates.valid.json", decodeContentScoutCandidatesFixture, false}, + {"valid empty candidates", "agents/content-scout/v1/fixtures/candidates.valid-empty.json", decodeContentScoutCandidatesFixture, false}, + {"candidate unknown field", "agents/content-scout/v1/fixtures/candidates.invalid-unknown-field.json", decodeContentScoutCandidatesFixture, true}, + {"too many candidates", "agents/content-scout/v1/fixtures/candidates.invalid-too-many.json", decodeContentScoutCandidatesFixture, true}, + {"oversized candidate", "agents/content-scout/v1/fixtures/candidates.invalid-oversized.json", decodeContentScoutCandidatesFixture, true}, + {"duplicate candidate", "agents/content-scout/v1/fixtures/candidates.invalid-duplicate.json", decodeContentScoutCandidatesFixture, true}, + {"empty suitable angle", "agents/content-scout/v1/fixtures/candidates.invalid-empty-suitable-angle.json", decodeContentScoutCandidatesFixture, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.decode(readContractFile(t, test.path)) + if (err != nil) != test.wantErr { + t.Fatalf("error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} + +func decodeExecutionRequestFixture(document []byte) error { + request, err := decodeContract[domain.AgentExecutionRequestV1](document) + if err != nil { + return err + } + if err := request.Validate(); err != nil { + return err + } + if request.Input.SchemaName != ContentScoutInputSchemaName || + request.Input.SchemaVersion != domain.ContentScoutInputSchemaVersion || + request.Input.SchemaDigest != ContentScoutInputSchemaDigest || + request.RequiredOutputSchema.Name != ContentScoutCandidatesSchemaName || + request.RequiredOutputSchema.Version != domain.ContentIdeaSchemaVersion || + request.RequiredOutputSchema.Digest != ContentScoutCandidatesSchemaDigest { + return errors.New("execution schema identity is invalid") + } + return decodeContentScoutInputFixture(request.Input.CanonicalJSON) +} + +func decodeExecutionResponseFixture(document []byte) error { + response, err := decodeContract[domain.AgentExecutionResponseV1](document) + if err != nil { + return err + } + if err := response.Validate(); err != nil { + return err + } + return decodeContentScoutCandidatesFixture(response.CandidateJSON) +} + +func decodeContentScoutInputFixture(document []byte) error { + value, err := decodeContract[domain.ContentScoutInputV1](document) + if err != nil { + return err + } + return value.Validate() +} + +func decodeContentScoutCandidatesFixture(document []byte) error { + value, err := decodeContract[domain.ContentScoutCandidatesV1](document) + if err != nil { + return err + } + return value.Validate() +} + +func decodeContract[T any](document []byte) (T, error) { + var value T + if len(document) == 0 || len(document) > maxAgentContractDocumentBytes { + return value, errors.New("contract document size is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return value, errors.New("contract document contains trailing JSON") + } + return value, nil +} + +func readContractFile(t *testing.T, relativePath string) []byte { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve contract fixture path") + } + path := filepath.Join(filepath.Dir(currentFile), "..", "..", "contracts", relativePath) + document, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + return document +} + +func assertFixedSchemaObjectsAreStrict(t *testing.T, value any, path string) { + t.Helper() + object, isObject := value.(map[string]any) + if isObject { + if object["type"] == "object" && !opaqueSchemaObject(path) { + if additional, exists := object["additionalProperties"]; !exists || additional != false { + t.Fatalf("fixed schema object %s does not reject unknown fields", path) + } + } + for key, child := range object { + assertFixedSchemaObjectsAreStrict(t, child, path+"/"+key) + } + return + } + if array, isArray := value.([]any); isArray { + for index, child := range array { + assertFixedSchemaObjectsAreStrict(t, child, fmt.Sprintf("%s/%d", path, index)) + } + } +} + +func opaqueSchemaObject(path string) bool { + return strings.HasSuffix(path, "/properties/handlerConfigurationJson") || + strings.HasSuffix(path, "/properties/canonicalJson") || + strings.HasSuffix(path, "/properties/candidateJson") +} diff --git a/internal/application/agent_execution.go b/internal/application/agent_execution.go new file mode 100644 index 0000000..6a6604c --- /dev/null +++ b/internal/application/agent_execution.go @@ -0,0 +1,67 @@ +package application + +import ( + "bytes" + "context" + "encoding/json" + "errors" + + contractassets "github.com/ferueda/noema/contracts" + "github.com/ferueda/noema/internal/domain" + "github.com/ferueda/noema/internal/platform" +) + +const ( + AgentExecutionRequestSchemaDigest = "695bb06e7a7b3698cf9205643e74814b901cccaa916364dc4f4cbe3ee74f37e4" + AgentExecutionResponseSchemaDigest = "57c279c25b6c1b21d00a95fa09a71b10397ee527595a3bbc9a42a5a86bf1f671" + ContentScoutInputSchemaName = "content-scout-input" + ContentScoutInputSchemaDigest = "7c431016c261bf3a3bc17aa12493f498e012d3168b70e5ec5f64ece10fcd900d" + ContentScoutCandidatesSchemaName = "content-scout-candidates" + ContentScoutCandidatesSchemaDigest = "6859d77424e02745841a7348a3c88e74874a928c4ef0f0b831257b81e5c8c965" +) + +// AgentExecutor is the portable execution boundary. Agent-specific input +// preparation and output admission remain in Noema. +type AgentExecutor interface { + Execute( + context.Context, + domain.AgentExecutionRequestV1, + domain.StructuredOutputSchema, + ) (domain.AgentExecutionResponseV1, error) +} + +func ContentScoutOutputSchema() (domain.StructuredOutputSchema, error) { + document, err := loadContractSchema( + "agents/content-scout/v1/candidates.schema.json", + ContentScoutCandidatesSchemaDigest, + ) + if err != nil { + return domain.StructuredOutputSchema{}, err + } + return domain.StructuredOutputSchema{ + Identity: domain.StructuredOutputSchemaIdentity{ + Name: ContentScoutCandidatesSchemaName, + Version: domain.ContentIdeaSchemaVersion, + Disposition: domain.StructuredOutputDispositionStrict, + Digest: ContentScoutCandidatesSchemaDigest, + }, + CanonicalJSON: document, + }, nil +} + +func loadContractSchema(path string, expectedDigest string) (json.RawMessage, error) { + document, err := contractassets.Files.ReadFile(path) + if err != nil { + return nil, errors.New("agent contract schema unavailable") + } + var compact bytes.Buffer + if err := json.Compact(&compact, document); err != nil { + return nil, errors.New("agent contract schema invalid") + } + canonical := json.RawMessage(compact.Bytes()) + digest, err := platform.Fingerprint(canonical) + if err != nil || digest != expectedDigest { + return nil, errors.New("agent contract schema identity mismatch") + } + return canonical, nil +} diff --git a/internal/domain/agent.go b/internal/domain/agent.go new file mode 100644 index 0000000..50a0e87 --- /dev/null +++ b/internal/domain/agent.go @@ -0,0 +1,614 @@ +package domain + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "math" + "regexp" + "time" + + "github.com/ferueda/noema/internal/platform" +) + +const ( + AgentJobPayloadSchemaVersion = 1 + AgentExecutionContractVersion = 1 + AgentRunResultSchemaVersion = 1 + AgentExecutionDispositionSkipped = "skipped-no-claims" + AgentExecutionDispositionNone = "not-invoked" + AgentExecutionDispositionInvoked = "invoked" + AgentRunOutcomeSucceeded = "succeeded" + AgentRunOutcomeFailed = "failed" + AgentPolicyOutcomePassed = "passed" + AgentPolicyOutcomeBlocked = "blocked" + AgentFailureStagePreparation = "preparation" + AgentFailureStageExecution = "execution" + AgentFailureStageResponseDecode = "response-decoding" + AgentFailureStageAdmission = "local-admission" + + AgentFailureCategoryConfigurationInvalid = "configuration-invalid" + AgentFailureCategoryInputInvalid = "input-invalid" + AgentFailureCategoryPrivacyBlocked = "privacy-blocked" + AgentFailureCategoryDisclosureBlocked = "disclosure-blocked" + AgentFailureCategoryExecutorUnavailable = "executor-unavailable" + AgentFailureCategoryExecutorMismatch = "executor-mismatch" + AgentFailureCategoryExecutorProtocol = "executor-protocol-invalid" + AgentFailureCategoryExecutorFailed = "executor-failed" + AgentFailureCategoryResponseInvalid = "response-invalid" + AgentFailureCategoryCandidateInvalid = "candidate-invalid" + AgentFailureCategoryPersistenceFailed = "persistence-failed" + + maxAgentIDBytes = 256 + maxAgentNameBytes = 128 + maxAgentConfigurationBytes = 64 * 1024 + maxAgentPayloadBytes = 256 * 1024 + maxAgentKnowledgeClaims = 128 + maxAgentArtifacts = 5 + maxAgentPolicyStages = 8 + maxAgentPolicyCategoryCounts = 32 + maxAgentCompletedModelSteps = 4_096 + maxAgentLatencyMilliseconds int64 = 24 * 60 * 60 * 1_000 +) + +var agentDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) +var agentSafeValuePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +type AgentIdentity struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type KnowledgeInputRefsV1 struct { + AnalysisRunID string `json:"analysisRunId"` + ClaimIDs []string `json:"claimIds"` +} + +type AgentRouteIdentity struct { + Alias string `json:"alias"` + Gateway string `json:"gateway"` + Model string `json:"model"` + Provider string `json:"provider"` + RouteVersion string `json:"routeVersion"` + ServiceTier string `json:"serviceTier"` +} + +type AgentConfigurationIdentity struct { + PromptVersion string `json:"promptVersion"` + OutputSchema StructuredOutputSchemaIdentity `json:"outputSchema"` + Route AgentRouteIdentity `json:"route"` + PrivacyPolicyVersion string `json:"privacyPolicyVersion"` + DisclosurePolicyVersion string `json:"disclosurePolicyVersion"` + SafetyPolicyVersion string `json:"safetyPolicyVersion"` + RetrievalPolicyVersion string `json:"retrievalPolicyVersion"` + Digest string `json:"digest"` + HandlerConfigurationJSON json.RawMessage `json:"handlerConfigurationJson"` +} + +type AgentJobPayloadV1 struct { + SchemaVersion int `json:"schemaVersion"` + Inputs KnowledgeInputRefsV1 `json:"inputs"` + Configuration AgentConfigurationIdentity `json:"configuration"` +} + +type AgentExecutionIdentity struct { + ExecutorKind string `json:"executorKind"` + ExecutorVersion string `json:"executorVersion"` + AgentDefinitionDigest string `json:"agentDefinitionDigest"` + ContractVersion int `json:"contractVersion"` + RecoveryPolicyVersion string `json:"recoveryPolicyVersion"` +} + +type AgentExecutionInputV1 struct { + SchemaName string `json:"schemaName"` + SchemaVersion int `json:"schemaVersion"` + SchemaDigest string `json:"schemaDigest"` + CanonicalJSON json.RawMessage `json:"canonicalJson"` +} + +type AgentExecutionAuthorityV1 struct { + AllowRemote bool `json:"allowRemote"` +} + +type AgentExecutionRequestV1 struct { + ContractVersion int `json:"contractVersion"` + JobID string `json:"jobId"` + JobFingerprint string `json:"jobFingerprint"` + TriggerEventID string `json:"triggerEventId"` + Agent AgentIdentity `json:"agent"` + Configuration AgentConfigurationIdentity `json:"configuration"` + Execution AgentExecutionIdentity `json:"execution"` + Input AgentExecutionInputV1 `json:"input"` + RequiredOutputSchema StructuredOutputSchemaIdentity `json:"requiredOutputSchema"` + DeadlineAt time.Time `json:"deadlineAt"` + Authority AgentExecutionAuthorityV1 `json:"authority"` +} + +type AgentUsageV1 struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` +} + +type AgentExecutionReceiptV1 struct { + ExecutorKind string `json:"executorKind,omitempty"` + ExecutorVersion string `json:"executorVersion,omitempty"` + SessionID string `json:"sessionId,omitempty"` + TurnID string `json:"turnId,omitempty"` + CompletedModelSteps int `json:"completedModelSteps"` + RequestedRoute *AgentRouteIdentity `json:"requestedRoute,omitempty"` + GatewayGenerationID string `json:"gatewayGenerationId,omitempty"` + Usage *AgentUsageV1 `json:"usage,omitempty"` + CostUSD string `json:"costUsd,omitempty"` + LatencyMilliseconds *int64 `json:"latencyMilliseconds,omitempty"` + FailureCategory string `json:"failureCategory,omitempty"` +} + +type AgentExecutionResponseV1 struct { + ContractVersion int `json:"contractVersion"` + CandidateJSON json.RawMessage `json:"candidateJson"` + Receipt AgentExecutionReceiptV1 `json:"receipt"` +} + +type AgentPolicyCategoryCountV1 struct { + Category string `json:"category"` + Count int `json:"count"` +} + +type AgentPolicyStageV1 struct { + Name string `json:"name"` + PolicyVersion string `json:"policyVersion"` + Outcome string `json:"outcome"` + Categories []AgentPolicyCategoryCountV1 `json:"categories"` +} + +type AgentPrivacyOutcomeV1 struct { + CompletedStages []AgentPolicyStageV1 `json:"completedStages"` +} + +type AgentFailureV1 struct { + Stage string `json:"stage"` + Category string `json:"category"` +} + +type AgentRunResultV1 struct { + SchemaVersion int `json:"schemaVersion"` + Outcome string `json:"outcome"` + Disposition string `json:"disposition"` + Execution *AgentExecutionIdentity `json:"execution,omitempty"` + Receipt *AgentExecutionReceiptV1 `json:"receipt,omitempty"` + Privacy AgentPrivacyOutcomeV1 `json:"privacy"` + Failure *AgentFailureV1 `json:"failure,omitempty"` + ArtifactIDs []string `json:"artifactIds"` +} + +func (value AgentIdentity) Validate() error { + if !validAgentName(value.Name) || !validAgentName(value.Version) { + return errors.New("agent identity is invalid") + } + return nil +} + +func (value KnowledgeInputRefsV1) Validate() error { + if !validAgentID(value.AnalysisRunID) || + len(value.ClaimIDs) > maxAgentKnowledgeClaims || + !validUniqueAgentIDs(value.ClaimIDs) { + return errors.New("knowledge input references are invalid") + } + return nil +} + +func (value AgentRouteIdentity) Validate() error { + for _, field := range []string{ + value.Alias, + value.Gateway, + value.Model, + value.Provider, + value.RouteVersion, + value.ServiceTier, + } { + if !validAgentName(field) { + return errors.New("agent route identity is invalid") + } + } + return nil +} + +func (value AgentConfigurationIdentity) Validate() error { + if !validAgentName(value.PromptVersion) || + !validStructuredOutputIdentity(value.OutputSchema) || + value.Route.Validate() != nil || + !validAgentName(value.PrivacyPolicyVersion) || + !validAgentName(value.DisclosurePolicyVersion) || + !validAgentName(value.SafetyPolicyVersion) || + !validAgentName(value.RetrievalPolicyVersion) || + !agentDigestPattern.MatchString(value.Digest) { + return errors.New("agent configuration identity is invalid") + } + if _, err := stableJSONObject(value.HandlerConfigurationJSON, maxAgentConfigurationBytes, 32); err != nil { + return errors.New("agent handler configuration is invalid") + } + digest, err := AgentConfigurationDigest(value) + if err != nil || digest != value.Digest { + return errors.New("agent configuration digest mismatch") + } + return nil +} + +func (value AgentJobPayloadV1) Validate() error { + if value.SchemaVersion != AgentJobPayloadSchemaVersion || + value.Inputs.Validate() != nil || + value.Configuration.Validate() != nil { + return errors.New("agent job payload is invalid") + } + return nil +} + +func (value AgentExecutionIdentity) Validate() error { + if !validAgentName(value.ExecutorKind) || + !validAgentName(value.ExecutorVersion) || + !agentDigestPattern.MatchString(value.AgentDefinitionDigest) || + value.ContractVersion != AgentExecutionContractVersion || + !validAgentName(value.RecoveryPolicyVersion) { + return errors.New("agent execution identity is invalid") + } + return nil +} + +func (value AgentExecutionInputV1) Validate() error { + if !validAgentName(value.SchemaName) || + value.SchemaVersion < 1 || + !agentDigestPattern.MatchString(value.SchemaDigest) { + return errors.New("agent execution input identity is invalid") + } + if _, err := compactJSONObject(value.CanonicalJSON, maxAgentPayloadBytes, 32); err != nil { + return errors.New("agent execution input is invalid") + } + return nil +} + +func (value AgentExecutionRequestV1) Validate() error { + if value.ContractVersion != AgentExecutionContractVersion || + !validAgentID(value.JobID) || + !agentDigestPattern.MatchString(value.JobFingerprint) || + !validAgentID(value.TriggerEventID) || + value.Agent.Validate() != nil || + value.Configuration.Validate() != nil || + value.Execution.Validate() != nil || + value.Input.Validate() != nil || + !validStructuredOutputIdentity(value.RequiredOutputSchema) || + value.RequiredOutputSchema != value.Configuration.OutputSchema || + value.DeadlineAt.IsZero() || + !value.Authority.AllowRemote { + return errors.New("agent execution request is invalid") + } + return nil +} + +func (value AgentUsageV1) Validate() error { + if value.InputTokens < 0 || value.OutputTokens < 0 || value.TotalTokens < 0 || + value.TotalTokens != value.InputTokens+value.OutputTokens { + return errors.New("agent usage is invalid") + } + return nil +} + +func (value AgentExecutionReceiptV1) ValidateComplete() error { + if !validAgentName(value.ExecutorKind) || + !validAgentName(value.ExecutorVersion) || + !validAgentID(value.SessionID) || + !validAgentID(value.TurnID) || + value.CompletedModelSteps < 1 || + value.CompletedModelSteps > maxAgentCompletedModelSteps || + value.RequestedRoute == nil || + value.RequestedRoute.Validate() != nil || + value.FailureCategory != "" { + return errors.New("agent execution receipt is invalid") + } + return value.validateOptionalMetadata() +} + +func (value AgentExecutionReceiptV1) ValidateObserved() error { + if value.ExecutorKind != "" && !validAgentName(value.ExecutorKind) || + value.ExecutorVersion != "" && !validAgentName(value.ExecutorVersion) || + value.SessionID != "" && !validAgentID(value.SessionID) || + value.TurnID != "" && !validAgentID(value.TurnID) || + value.CompletedModelSteps < 0 || + value.CompletedModelSteps > maxAgentCompletedModelSteps || + value.RequestedRoute != nil && value.RequestedRoute.Validate() != nil || + value.FailureCategory != "" && !validAgentFailureCategory(value.FailureCategory) { + return errors.New("observed agent execution receipt is invalid") + } + if value.ExecutorKind == "" && + value.ExecutorVersion == "" && + value.SessionID == "" && + value.TurnID == "" && + value.CompletedModelSteps == 0 && + value.RequestedRoute == nil && + value.GatewayGenerationID == "" && + value.Usage == nil && + value.CostUSD == "" && + value.LatencyMilliseconds == nil && + value.FailureCategory == "" { + return errors.New("observed agent execution receipt is empty") + } + return value.validateOptionalMetadata() +} + +func (value AgentExecutionReceiptV1) validateOptionalMetadata() error { + if value.GatewayGenerationID != "" && !validAgentID(value.GatewayGenerationID) { + return errors.New("agent gateway generation identity is invalid") + } + if value.Usage != nil && value.Usage.Validate() != nil { + return errors.New("agent receipt usage is invalid") + } + if value.CostUSD != "" && !ValidModelCostUSD(value.CostUSD) { + return errors.New("agent receipt cost is invalid") + } + if value.LatencyMilliseconds != nil && + (*value.LatencyMilliseconds < 0 || *value.LatencyMilliseconds > maxAgentLatencyMilliseconds) { + return errors.New("agent receipt latency is invalid") + } + return nil +} + +func (value AgentExecutionResponseV1) Validate() error { + if value.ContractVersion != AgentExecutionContractVersion || + value.Receipt.ValidateComplete() != nil { + return errors.New("agent execution response is invalid") + } + if _, err := compactJSONObject(value.CandidateJSON, maxAgentPayloadBytes, 32); err != nil { + return errors.New("agent candidate result is invalid") + } + return nil +} + +func (value AgentPrivacyOutcomeV1) Validate() error { + if len(value.CompletedStages) > maxAgentPolicyStages { + return errors.New("agent privacy outcome is invalid") + } + seen := make(map[string]struct{}, len(value.CompletedStages)) + for _, stage := range value.CompletedStages { + if !validSafeAgentValue(stage.Name) || + !validAgentName(stage.PolicyVersion) || + (stage.Outcome != AgentPolicyOutcomePassed && stage.Outcome != AgentPolicyOutcomeBlocked) || + len(stage.Categories) > maxAgentPolicyCategoryCounts { + return errors.New("agent privacy stage is invalid") + } + if _, duplicate := seen[stage.Name]; duplicate { + return errors.New("agent privacy stage is duplicated") + } + seen[stage.Name] = struct{}{} + seenCategories := make(map[string]struct{}, len(stage.Categories)) + for _, category := range stage.Categories { + if !validSafeAgentValue(category.Category) || category.Count < 1 { + return errors.New("agent privacy category count is invalid") + } + if _, duplicate := seenCategories[category.Category]; duplicate { + return errors.New("agent privacy category is duplicated") + } + seenCategories[category.Category] = struct{}{} + } + } + return nil +} + +func (value AgentPrivacyOutcomeV1) passed() bool { + for _, stage := range value.CompletedStages { + if stage.Outcome != AgentPolicyOutcomePassed { + return false + } + } + return true +} + +func (value AgentRunResultV1) Validate() error { + if value.SchemaVersion != AgentRunResultSchemaVersion || + value.Privacy.Validate() != nil || + len(value.ArtifactIDs) > maxAgentArtifacts || + !validUniqueAgentIDs(value.ArtifactIDs) { + return errors.New("agent run result is invalid") + } + switch { + case value.Outcome == AgentRunOutcomeSucceeded && + value.Disposition == AgentExecutionDispositionSkipped: + if value.Execution != nil || value.Receipt != nil || value.Failure != nil || + len(value.ArtifactIDs) != 0 || len(value.Privacy.CompletedStages) != 0 { + return errors.New("skipped agent result is invalid") + } + case value.Outcome == AgentRunOutcomeSucceeded && + value.Disposition == AgentExecutionDispositionInvoked: + if value.Execution == nil || value.Execution.Validate() != nil || + value.Receipt == nil || value.Receipt.ValidateComplete() != nil || + value.Failure != nil || !value.Privacy.passed() { + return errors.New("successful invoked agent result is invalid") + } + case value.Outcome == AgentRunOutcomeFailed && + value.Disposition == AgentExecutionDispositionNone: + if value.Receipt != nil || len(value.ArtifactIDs) != 0 || + value.Failure == nil || value.Failure.Stage != AgentFailureStagePreparation || + !validFailure(value.Failure) || + value.Execution != nil && value.Execution.Validate() != nil { + return errors.New("failed non-invoked agent result is invalid") + } + case value.Outcome == AgentRunOutcomeFailed && + value.Disposition == AgentExecutionDispositionInvoked: + if value.Execution == nil || value.Execution.Validate() != nil || + len(value.ArtifactIDs) != 0 || + value.Failure == nil || value.Failure.Stage == AgentFailureStagePreparation || + !validFailure(value.Failure) || + value.Receipt != nil && value.Receipt.ValidateObserved() != nil { + return errors.New("failed invoked agent result is invalid") + } + default: + return errors.New("agent run outcome and disposition are invalid") + } + return nil +} + +func AgentConfigurationDigest(value AgentConfigurationIdentity) (string, error) { + canonical, err := stableJSONObject(value.HandlerConfigurationJSON, maxAgentConfigurationBytes, 32) + if err != nil { + return "", err + } + return platform.Fingerprint(struct { + PromptVersion string `json:"promptVersion"` + OutputSchema StructuredOutputSchemaIdentity `json:"outputSchema"` + Route AgentRouteIdentity `json:"route"` + PrivacyPolicyVersion string `json:"privacyPolicyVersion"` + DisclosurePolicyVersion string `json:"disclosurePolicyVersion"` + SafetyPolicyVersion string `json:"safetyPolicyVersion"` + RetrievalPolicyVersion string `json:"retrievalPolicyVersion"` + HandlerConfiguration json.RawMessage `json:"handlerConfiguration"` + }{ + PromptVersion: value.PromptVersion, + OutputSchema: value.OutputSchema, + Route: value.Route, + PrivacyPolicyVersion: value.PrivacyPolicyVersion, + DisclosurePolicyVersion: value.DisclosurePolicyVersion, + SafetyPolicyVersion: value.SafetyPolicyVersion, + RetrievalPolicyVersion: value.RetrievalPolicyVersion, + HandlerConfiguration: canonical, + }) +} + +func AgentJobFingerprint( + triggerEventID string, + agent AgentIdentity, + payload AgentJobPayloadV1, +) (string, error) { + if !validAgentID(triggerEventID) || agent.Validate() != nil || payload.Validate() != nil { + return "", errors.New("agent job fingerprint input is invalid") + } + return platform.Fingerprint(struct { + TriggerEventID string `json:"triggerEventId"` + Agent AgentIdentity `json:"agent"` + SchemaVersion int `json:"schemaVersion"` + Inputs KnowledgeInputRefsV1 `json:"inputs"` + ConfigurationDigest string `json:"configurationDigest"` + }{ + TriggerEventID: triggerEventID, Agent: agent, + SchemaVersion: payload.SchemaVersion, Inputs: payload.Inputs, + ConfigurationDigest: payload.Configuration.Digest, + }) +} + +func validFailure(value *AgentFailureV1) bool { + if value == nil || !validAgentFailureCategory(value.Category) { + return false + } + switch value.Stage { + case AgentFailureStagePreparation, AgentFailureStageExecution, + AgentFailureStageResponseDecode, AgentFailureStageAdmission: + return true + default: + return false + } +} + +func validAgentFailureCategory(value string) bool { + switch value { + case AgentFailureCategoryConfigurationInvalid, + AgentFailureCategoryInputInvalid, + AgentFailureCategoryPrivacyBlocked, + AgentFailureCategoryDisclosureBlocked, + AgentFailureCategoryExecutorUnavailable, + AgentFailureCategoryExecutorMismatch, + AgentFailureCategoryExecutorProtocol, + AgentFailureCategoryExecutorFailed, + AgentFailureCategoryResponseInvalid, + AgentFailureCategoryCandidateInvalid, + AgentFailureCategoryPersistenceFailed: + return true + default: + return false + } +} + +func validStructuredOutputIdentity(value StructuredOutputSchemaIdentity) bool { + return validAgentName(value.Name) && + value.Version >= 1 && + value.Disposition == StructuredOutputDispositionStrict && + agentDigestPattern.MatchString(value.Digest) +} + +func validAgentID(value string) bool { + return len(value) > 0 && len(value) <= maxAgentIDBytes +} + +func validAgentName(value string) bool { + return len(value) > 0 && len(value) <= maxAgentNameBytes +} + +func validSafeAgentValue(value string) bool { + return len(value) > 0 && + len(value) <= maxAgentNameBytes && + agentSafeValuePattern.MatchString(value) +} + +func validUniqueAgentIDs(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if !validAgentID(value) { + return false + } + if _, duplicate := seen[value]; duplicate { + return false + } + seen[value] = struct{}{} + } + return true +} + +func compactJSONObject( + value json.RawMessage, + maxBytes int, + maxProperties int, +) (json.RawMessage, error) { + if len(value) == 0 || len(value) > maxBytes || !json.Valid(value) { + return nil, errors.New("json object is invalid") + } + var object map[string]json.RawMessage + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&object); err != nil || object == nil || len(object) > maxProperties { + return nil, errors.New("json object is invalid") + } + if err := decoder.Decode(&json.RawMessage{}); !errors.Is(err, io.EOF) { + return nil, errors.New("json object contains trailing data") + } + var compact bytes.Buffer + if err := json.Compact(&compact, value); err != nil { + return nil, errors.New("json object is invalid") + } + return json.RawMessage(compact.Bytes()), nil +} + +func stableJSONObject( + value json.RawMessage, + maxBytes int, + maxProperties int, +) (json.RawMessage, error) { + if len(value) == 0 || len(value) > maxBytes || !json.Valid(value) { + return nil, errors.New("json object is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil || object == nil || len(object) > maxProperties { + return nil, errors.New("json object is invalid") + } + if err := decoder.Decode(&json.RawMessage{}); !errors.Is(err, io.EOF) { + return nil, errors.New("json object contains trailing data") + } + canonical, err := json.Marshal(object) + if err != nil { + return nil, errors.New("json object is invalid") + } + return canonical, nil +} + +func validConfidence(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value <= 1 +} diff --git a/internal/domain/agent_test.go b/internal/domain/agent_test.go new file mode 100644 index 0000000..709ac6e --- /dev/null +++ b/internal/domain/agent_test.go @@ -0,0 +1,559 @@ +package domain + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ferueda/noema/internal/platform" +) + +func TestAgentConfigurationAndJobFingerprintsAreStableAndConfigurationSensitive(t *testing.T) { + configuration := validAgentConfiguration(t) + payload := AgentJobPayloadV1{ + SchemaVersion: AgentJobPayloadSchemaVersion, + Inputs: KnowledgeInputRefsV1{ + AnalysisRunID: "analysis_one", + ClaimIDs: []string{"claim_one", "claim_two"}, + }, + Configuration: configuration, + } + if err := payload.Validate(); err != nil { + t.Fatalf("validate payload: %v", err) + } + first, err := AgentJobFingerprint( + "event_one", + AgentIdentity{Name: "content-scout", Version: "content-scout-v1"}, + payload, + ) + if err != nil { + t.Fatalf("fingerprint job: %v", err) + } + second, err := AgentJobFingerprint( + "event_one", + AgentIdentity{Name: "content-scout", Version: "content-scout-v1"}, + payload, + ) + if err != nil || second != first { + t.Fatalf("repeat fingerprint = %q, %v; want %q", second, err, first) + } + + changed := configuration + changed.HandlerConfigurationJSON = json.RawMessage(`{"approvedPublicTerms":["Go","SQLite"]}`) + changed.Digest, err = AgentConfigurationDigest(changed) + if err != nil { + t.Fatalf("digest changed configuration: %v", err) + } + payload.Configuration = changed + changedFingerprint, err := AgentJobFingerprint( + "event_one", + AgentIdentity{Name: "content-scout", Version: "content-scout-v1"}, + payload, + ) + if err != nil { + t.Fatalf("fingerprint changed job: %v", err) + } + if changedFingerprint == first { + t.Fatal("changed configuration reused the original job fingerprint") + } +} + +func TestAgentConfigurationCanonicalizesHandlerConfigurationForIdentity(t *testing.T) { + configuration := validAgentConfiguration(t) + originalDigest := configuration.Digest + configuration.HandlerConfigurationJSON = json.RawMessage(`{ + "limits": {"maximumIdeas": 5, "maximumClaims": 128}, + "approvedPublicTerms": ["Go"] + }`) + configuration.Digest = "" + first, err := AgentConfigurationDigest(configuration) + if err != nil { + t.Fatalf("digest formatted configuration: %v", err) + } + configuration.HandlerConfigurationJSON = json.RawMessage( + `{"approvedPublicTerms":["Go"],"limits":{"maximumClaims":128,"maximumIdeas":5}}`, + ) + second, err := AgentConfigurationDigest(configuration) + if err != nil { + t.Fatalf("digest reordered configuration: %v", err) + } + if first != second { + t.Fatalf("configuration digests differ: %q != %q", first, second) + } + + configuration = validAgentConfiguration(t) + configuration.Digest = strings.Repeat("f", 64) + if err := configuration.Validate(); err == nil { + t.Fatal("mismatched configuration digest was accepted") + } + if originalDigest == configuration.Digest { + t.Fatal("test did not change the configuration digest") + } +} + +func TestAgentRunResultEnforcesOutcomeAndInvocationMatrix(t *testing.T) { + execution := validExecutionIdentity() + receipt := validExecutionReceipt() + tests := []struct { + name string + result AgentRunResultV1 + wantErr bool + }{ + { + name: "skipped no claims", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeSucceeded, + Disposition: AgentExecutionDispositionSkipped, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + ArtifactIDs: []string{}, + }, + }, + { + name: "successful invocation", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeSucceeded, + Disposition: AgentExecutionDispositionInvoked, + Execution: &execution, + Receipt: &receipt, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{{ + Name: "postflight", PolicyVersion: "deterministic-privacy-v1", + Outcome: AgentPolicyOutcomePassed, Categories: []AgentPolicyCategoryCountV1{}, + }}}, + ArtifactIDs: []string{"artifact_one"}, + }, + }, + { + name: "pre-executor failure", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionNone, + Execution: &execution, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStagePreparation, Category: "input-invalid", + }, + ArtifactIDs: []string{}, + }, + }, + { + name: "invoked failure without receipt", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionInvoked, + Execution: &execution, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStageExecution, Category: "executor-unavailable", + }, + ArtifactIDs: []string{}, + }, + }, + { + name: "post-executor admission mislabeled not invoked", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionNone, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStageAdmission, Category: "candidate-invalid", + }, + ArtifactIDs: []string{}, + }, + wantErr: true, + }, + { + name: "skipped with receipt", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeSucceeded, + Disposition: AgentExecutionDispositionSkipped, + Receipt: &receipt, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + ArtifactIDs: []string{}, + }, + wantErr: true, + }, + { + name: "failed with artifact", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionInvoked, + Execution: &execution, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStageAdmission, Category: "candidate-invalid", + }, + ArtifactIDs: []string{"artifact_one"}, + }, + wantErr: true, + }, + { + name: "invoked failure with empty observed receipt", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionInvoked, + Execution: &execution, + Receipt: &AgentExecutionReceiptV1{}, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStageExecution, Category: AgentFailureCategoryExecutorFailed, + }, + ArtifactIDs: []string{}, + }, + wantErr: true, + }, + { + name: "successful invocation with blocked policy", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeSucceeded, + Disposition: AgentExecutionDispositionInvoked, + Execution: &execution, + Receipt: &receipt, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{{ + Name: "postflight", PolicyVersion: "deterministic-privacy-v1", + Outcome: AgentPolicyOutcomeBlocked, Categories: []AgentPolicyCategoryCountV1{}, + }}}, + ArtifactIDs: []string{"artifact_one"}, + }, + wantErr: true, + }, + { + name: "failed with unregistered category", + result: AgentRunResultV1{ + SchemaVersion: AgentRunResultSchemaVersion, + Outcome: AgentRunOutcomeFailed, + Disposition: AgentExecutionDispositionNone, + Privacy: AgentPrivacyOutcomeV1{CompletedStages: []AgentPolicyStageV1{}}, + Failure: &AgentFailureV1{ + Stage: AgentFailureStagePreparation, Category: "made-up-category", + }, + ArtifactIDs: []string{}, + }, + wantErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.result.Validate() + if (err != nil) != test.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, test.wantErr) + } + }) + } +} + +func TestAgentExecutionRequestAndResponseValidatePortableEnvelope(t *testing.T) { + configuration := validAgentConfiguration(t) + request := AgentExecutionRequestV1{ + ContractVersion: AgentExecutionContractVersion, + JobID: "job_one", + JobFingerprint: strings.Repeat("1", 64), + TriggerEventID: "event_one", + Agent: AgentIdentity{Name: "content-scout", Version: "content-scout-v1"}, + Configuration: configuration, + Execution: validExecutionIdentity(), + Input: AgentExecutionInputV1{ + SchemaName: "content-scout-input", SchemaVersion: 1, + SchemaDigest: strings.Repeat("2", 64), + CanonicalJSON: json.RawMessage(`{"analysisRunId":"analysis_one","schemaVersion":1}`), + }, + RequiredOutputSchema: configuration.OutputSchema, + DeadlineAt: time.Date(2026, 7, 28, 18, 0, 0, 0, time.UTC), + Authority: AgentExecutionAuthorityV1{AllowRemote: true}, + } + if err := request.Validate(); err != nil { + t.Fatalf("validate execution request: %v", err) + } + response := AgentExecutionResponseV1{ + ContractVersion: AgentExecutionContractVersion, + CandidateJSON: json.RawMessage(`{"ideas":[]}`), + Receipt: validExecutionReceipt(), + } + if err := response.Validate(); err != nil { + t.Fatalf("validate execution response: %v", err) + } + response.CandidateJSON = json.RawMessage(`{ "ideas": [] }`) + if err := response.Validate(); err != nil { + t.Fatalf("validate formatted candidate JSON: %v", err) + } +} + +func TestContentIdeaArtifactFingerprintIgnoresRankButCommitsToContentAndLineage(t *testing.T) { + idea := validContentIdea() + evidence := []EvidenceRef{{ID: "evidence_one"}} + first, err := ContentIdeaArtifactFingerprint( + strings.Repeat("1", 64), + idea, + []string{"fact_one"}, + evidence, + []EvidenceRef{}, + ArtifactSafetyReviewRequired, + ) + if err != nil { + t.Fatalf("fingerprint artifact: %v", err) + } + idea.Rank = 2 + reordered, err := ContentIdeaArtifactFingerprint( + strings.Repeat("1", 64), + idea, + []string{"fact_one"}, + evidence, + []EvidenceRef{}, + ArtifactSafetyReviewRequired, + ) + if err != nil || reordered != first { + t.Fatalf("rank-only fingerprint = %q, %v; want %q", reordered, err, first) + } + idea.Hook = "A different hook" + changed, err := ContentIdeaArtifactFingerprint( + strings.Repeat("1", 64), + idea, + []string{"fact_one"}, + evidence, + []EvidenceRef{}, + ArtifactSafetyReviewRequired, + ) + if err != nil { + t.Fatalf("fingerprint changed artifact: %v", err) + } + if changed == first { + t.Fatal("changed content reused the original artifact fingerprint") + } +} + +func TestContentScoutCandidatesRejectExactDuplicates(t *testing.T) { + idea := validContentIdea() + candidate := ContentIdeaCandidateV1{ + Concept: idea.Concept, CoreLesson: idea.CoreLesson, + AudienceBenefit: idea.AudienceBenefit, Hook: idea.Hook, + Resonance: idea.Resonance, Confidence: idea.Confidence, + ShortPost: idea.ShortPost, Thread: idea.Thread, Article: idea.Article, + ClaimIDs: idea.ClaimIDs, + } + if err := (ContentScoutCandidatesV1{ + Ideas: []ContentIdeaCandidateV1{candidate, candidate}, + }).Validate(); err == nil { + t.Fatal("duplicate content candidates were accepted") + } +} + +func validAgentConfiguration(t *testing.T) AgentConfigurationIdentity { + t.Helper() + value := AgentConfigurationIdentity{ + PromptVersion: "content-scout-prompt-v1", + OutputSchema: StructuredOutputSchemaIdentity{ + Name: "content-scout-candidates", Version: 1, + Disposition: StructuredOutputDispositionStrict, + Digest: strings.Repeat("a", 64), + }, + Route: AgentRouteIdentity{ + Alias: "content-scout-v1", Gateway: "vercel-ai-gateway", + Model: "openai/gpt-5.4-mini", Provider: "azure", + RouteVersion: "content-scout-route-v1", ServiceTier: "flex", + }, + PrivacyPolicyVersion: "deterministic-privacy-v1", + DisclosurePolicyVersion: "content-disclosure-v1", + SafetyPolicyVersion: "content-safety-v1", + RetrievalPolicyVersion: "content-scout-knowledge-v1", + HandlerConfigurationJSON: json.RawMessage(`{"approvedPublicTerms":["Go"]}`), + } + var err error + value.Digest, err = AgentConfigurationDigest(value) + if err != nil { + t.Fatalf("digest configuration: %v", err) + } + return value +} + +func validExecutionIdentity() AgentExecutionIdentity { + return AgentExecutionIdentity{ + ExecutorKind: "eve", ExecutorVersion: "0.27.8", + AgentDefinitionDigest: strings.Repeat("b", 64), + ContractVersion: AgentExecutionContractVersion, + RecoveryPolicyVersion: "eve-0.27.8-default-recovery-v1", + } +} + +func validExecutionReceipt() AgentExecutionReceiptV1 { + latency := int64(850) + return AgentExecutionReceiptV1{ + ExecutorKind: "eve", ExecutorVersion: "0.27.8", + SessionID: "session_one", TurnID: "turn_one", CompletedModelSteps: 1, + RequestedRoute: &AgentRouteIdentity{ + Alias: "content-scout-v1", Gateway: "vercel-ai-gateway", + Model: "openai/gpt-5.4-mini", Provider: "azure", + RouteVersion: "content-scout-route-v1", ServiceTier: "flex", + }, + GatewayGenerationID: "generation_one", + Usage: &AgentUsageV1{ + InputTokens: 300, OutputTokens: 140, TotalTokens: 440, + }, + CostUSD: "0.00042", LatencyMilliseconds: &latency, + } +} + +func validContentIdea() ContentIdeaV1 { + return ContentIdeaV1{ + Rank: 1, Concept: "Portable agent boundaries", + CoreLesson: "Keep durable domain authority outside the executor.", + AudienceBenefit: "Developers can replace agent runtimes safely.", + Hook: "Your agent framework should be replaceable.", + Resonance: "Early experiments often leak framework choices into the core.", + Confidence: 0.9, + ShortPost: ContentFormatAngleV1{ + Suitable: true, Angle: "State the boundary and its benefit.", + }, + Thread: ContentFormatAngleV1{ + Suitable: true, Angle: "Walk through event, job, and artifact ownership.", + }, + Article: ContentFormatAngleV1{ + Suitable: true, Angle: "Compare portable and framework-owned architectures.", + }, + ClaimIDs: []string{"claim_one"}, + } +} + +func TestArtifactEnvelopeValidatesCanonicalPayloadAndDerivedIdentity(t *testing.T) { + idea := validContentIdea() + marshalIdea := func(value ContentIdeaV1) json.RawMessage { + t.Helper() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("encode idea: %v", err) + } + return payload + } + payload := marshalIdea(idea) + jobFingerprint := strings.Repeat("1", 64) + evidence := []EvidenceRef{{ID: "evidence_one"}} + fingerprint, err := ContentIdeaArtifactFingerprint( + jobFingerprint, idea, []string{"fact_one"}, evidence, + []EvidenceRef{}, ArtifactSafetyReviewRequired, + ) + if err != nil { + t.Fatalf("fingerprint artifact: %v", err) + } + artifact := Artifact{ + ID: platform.DerivedID("artifact_", fingerprint), Fingerprint: fingerprint, + Kind: ArtifactKindContentIdea, SchemaVersion: ContentIdeaSchemaVersion, + PayloadJSON: payload, RunID: "run_one", TriggerEventID: "event_one", + JobFingerprint: jobFingerprint, + Inputs: KnowledgeInputRefsV1{ + AnalysisRunID: "analysis_one", ClaimIDs: []string{"claim_one"}, + }, + ClaimIDs: []string{"claim_one"}, FactIDs: []string{"fact_one"}, + SupportingEvidence: evidence, ContradictingEvidence: []EvidenceRef{}, + ProposalStatus: ArtifactProposalReviewRequired, + SafetyStatus: ArtifactSafetyReviewRequired, + CreatedAt: time.Date(2026, 7, 28, 18, 0, 0, 0, time.UTC), + } + if err := artifact.Validate(); err != nil { + t.Fatalf("validate artifact: %v", err) + } + reranked := artifact + rerankedIdea := idea + rerankedIdea.Rank = 2 + reranked.PayloadJSON = marshalIdea(rerankedIdea) + if err := reranked.Validate(); err != nil { + t.Fatalf("validate rank-only artifact change: %v", err) + } + + tests := []struct { + name string + mutate func(*Artifact) + }{ + {"mismatched id", func(value *Artifact) { value.ID = "artifact_wrong" }}, + {"unknown payload field", func(value *Artifact) { + value.PayloadJSON = json.RawMessage(`{"unexpected":true}`) + }}, + {"unsafe proposal status", func(value *Artifact) { value.ProposalStatus = "published" }}, + {"unsafe safety status", func(value *Artifact) { value.SafetyStatus = "safe" }}, + {"payload claim mismatch", func(value *Artifact) { + changed := idea + changed.ClaimIDs = []string{"claim_other"} + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale concept fingerprint", func(value *Artifact) { + changed := idea + changed.Concept = "A changed concept" + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale core lesson fingerprint", func(value *Artifact) { + changed := idea + changed.CoreLesson = "A changed lesson." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale audience benefit fingerprint", func(value *Artifact) { + changed := idea + changed.AudienceBenefit = "A changed benefit." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale hook fingerprint", func(value *Artifact) { + changed := idea + changed.Hook = "A changed hook." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale resonance fingerprint", func(value *Artifact) { + changed := idea + changed.Resonance = "A changed reason." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale confidence fingerprint", func(value *Artifact) { + changed := idea + changed.Confidence = 0.8 + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale short post fingerprint", func(value *Artifact) { + changed := idea + changed.ShortPost.Angle = "A changed short-post angle." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale thread fingerprint", func(value *Artifact) { + changed := idea + changed.Thread.Angle = "A changed thread angle." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale article fingerprint", func(value *Artifact) { + changed := idea + changed.Article.Angle = "A changed article angle." + value.PayloadJSON = marshalIdea(changed) + }}, + {"stale claim lineage fingerprint", func(value *Artifact) { + changed := idea + changed.ClaimIDs = []string{"claim_other"} + value.PayloadJSON = marshalIdea(changed) + value.ClaimIDs = []string{"claim_other"} + value.Inputs.ClaimIDs = []string{"claim_other"} + }}, + {"stale fact lineage fingerprint", func(value *Artifact) { + value.FactIDs = []string{"fact_other"} + }}, + {"stale supporting evidence fingerprint", func(value *Artifact) { + value.SupportingEvidence = []EvidenceRef{{ID: "evidence_other"}} + }}, + {"stale contradicting evidence fingerprint", func(value *Artifact) { + value.ContradictingEvidence = []EvidenceRef{{ID: "evidence_other"}} + }}, + {"stale job fingerprint", func(value *Artifact) { + value.JobFingerprint = strings.Repeat("2", 64) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + changed := artifact + test.mutate(&changed) + if err := changed.Validate(); err == nil { + t.Fatal("invalid artifact was accepted") + } + }) + } +} diff --git a/internal/domain/artifact.go b/internal/domain/artifact.go new file mode 100644 index 0000000..63ec91c --- /dev/null +++ b/internal/domain/artifact.go @@ -0,0 +1,455 @@ +package domain + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "slices" + "time" + + "github.com/ferueda/noema/internal/platform" +) + +const ( + ContentScoutInputSchemaVersion = 1 + ContentIdeaSchemaVersion = 1 + ArtifactKindContentIdea = "content-idea" + ArtifactProposalReviewRequired = "review-required" + ArtifactSafetyReviewRequired = "review-required" + ContentScoutCoveragePartial = "partial" + + maxContentScoutFacts = 256 + maxContentTextBytes = 4_096 + maxContentIdeaTextBytes = 1_024 + maxContentTechnicalBytes = 512 + maxContentEvidencePerItem = 256 +) + +type ContentScoutCoverageV1 struct { + Scope string `json:"scope"` + SelectedClaimCount int `json:"selectedClaimCount"` + SelectedFactCount int `json:"selectedFactCount"` +} + +type ContentScoutClaimV1 struct { + ID string `json:"id"` + Type ClaimType `json:"type"` + Statement string `json:"statement"` + Status ClaimStatus `json:"status"` + Confidence float64 `json:"confidence"` + Outcome *string `json:"outcome"` + SupportingFactIDs []string `json:"supportingFactIds"` + SupportingEvidenceIDs []string `json:"supportingEvidenceIds"` + ContradictingEvidenceIDs []string `json:"contradictingEvidenceIds"` +} + +type ContentScoutToolValueV1 struct { + Kind string `json:"kind"` + Name *string `json:"name"` + Namespace *string `json:"namespace"` +} + +type ContentScoutSelectedTextV1 struct { + Text string `json:"text"` + EmittedUTF8Bytes int `json:"emittedUtf8Bytes"` + OriginalUTF8Bytes int `json:"originalUtf8Bytes"` + Truncated bool `json:"truncated"` +} + +type ContentScoutTestValueV1 struct { + Framework string `json:"framework"` + Command *ContentScoutSelectedTextV1 `json:"command"` + Passed *int `json:"passed"` + Failed *int `json:"failed"` + Skipped *int `json:"skipped"` +} + +type ContentScoutFactValueV1 struct { + Tool *ContentScoutToolValueV1 `json:"tool"` + Command *ContentScoutSelectedTextV1 `json:"command"` + Test *ContentScoutTestValueV1 `json:"test"` + ExitCode *int `json:"exitCode"` + Error *ContentScoutSelectedTextV1 `json:"error"` +} + +type ContentScoutFactV1 struct { + ID string `json:"id"` + Kind string `json:"kind"` + Outcome string `json:"outcome"` + Value ContentScoutFactValueV1 `json:"value"` + EvidenceIDs []string `json:"evidenceIds"` +} + +type ContentScoutInputV1 struct { + SchemaVersion int `json:"schemaVersion"` + AnalysisRunID string `json:"analysisRunId"` + Coverage ContentScoutCoverageV1 `json:"coverage"` + Claims []ContentScoutClaimV1 `json:"claims"` + Facts []ContentScoutFactV1 `json:"facts"` + Omissions AnalysisOmissions `json:"omissions"` +} + +type ContentFormatAngleV1 struct { + Suitable bool `json:"suitable"` + Angle string `json:"angle"` +} + +type ContentIdeaCandidateV1 struct { + Concept string `json:"concept"` + CoreLesson string `json:"coreLesson"` + AudienceBenefit string `json:"audienceBenefit"` + Hook string `json:"hook"` + Resonance string `json:"resonance"` + Confidence float64 `json:"confidence"` + ShortPost ContentFormatAngleV1 `json:"shortPost"` + Thread ContentFormatAngleV1 `json:"thread"` + Article ContentFormatAngleV1 `json:"article"` + ClaimIDs []string `json:"claimIds"` +} + +type ContentScoutCandidatesV1 struct { + Ideas []ContentIdeaCandidateV1 `json:"ideas"` +} + +type ContentIdeaV1 struct { + Rank int `json:"rank"` + Concept string `json:"concept"` + CoreLesson string `json:"coreLesson"` + AudienceBenefit string `json:"audienceBenefit"` + Hook string `json:"hook"` + Resonance string `json:"resonance"` + Confidence float64 `json:"confidence"` + ShortPost ContentFormatAngleV1 `json:"shortPost"` + Thread ContentFormatAngleV1 `json:"thread"` + Article ContentFormatAngleV1 `json:"article"` + ClaimIDs []string `json:"claimIds"` +} + +type Artifact struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Kind string `json:"kind"` + SchemaVersion int `json:"schemaVersion"` + PayloadJSON json.RawMessage `json:"payloadJson"` + RunID string `json:"runId"` + TriggerEventID string `json:"triggerEventId"` + JobFingerprint string `json:"jobFingerprint"` + Inputs KnowledgeInputRefsV1 `json:"inputs"` + ClaimIDs []string `json:"claimIds"` + FactIDs []string `json:"factIds"` + SupportingEvidence []EvidenceRef `json:"supportingEvidence"` + ContradictingEvidence []EvidenceRef `json:"contradictingEvidence"` + ProposalStatus string `json:"proposalStatus"` + SafetyStatus string `json:"safetyStatus"` + CreatedAt time.Time `json:"createdAt"` +} + +func (value ContentScoutInputV1) Validate() error { + if value.SchemaVersion != ContentScoutInputSchemaVersion || + !validAgentID(value.AnalysisRunID) || + len(value.Claims) > maxAgentKnowledgeClaims || + len(value.Facts) > maxContentScoutFacts || + value.Coverage.SelectedClaimCount != len(value.Claims) || + value.Coverage.SelectedFactCount != len(value.Facts) || + (value.Coverage.Scope != CoverageCompleteRetainedSnapshot && + value.Coverage.Scope != ContentScoutCoveragePartial) || + value.Omissions.CanonicalSegments < 0 || + value.Omissions.OmittedTextFactCount < 0 || + value.Omissions.OmittedTextOriginalUTF8Bytes < 0 { + return errors.New("content scout input is invalid") + } + claimIDs := make([]string, 0, len(value.Claims)) + for _, claim := range value.Claims { + if claim.Validate() != nil { + return errors.New("content scout claim input is invalid") + } + claimIDs = append(claimIDs, claim.ID) + } + if !validUniqueAgentIDs(claimIDs) { + return errors.New("content scout claim inputs are duplicated") + } + factIDs := make([]string, 0, len(value.Facts)) + for _, fact := range value.Facts { + if fact.Validate() != nil { + return errors.New("content scout fact input is invalid") + } + factIDs = append(factIDs, fact.ID) + } + if !validUniqueAgentIDs(factIDs) { + return errors.New("content scout fact inputs are duplicated") + } + return nil +} + +func (value ContentScoutClaimV1) Validate() error { + if !validAgentID(value.ID) || + !value.Type.Valid() || + len(value.Statement) == 0 || + len(value.Statement) > maxContentTextBytes || + !value.Status.Valid() || + !validConfidence(value.Confidence) || + len(value.SupportingFactIDs) > maxContentEvidencePerItem || + len(value.SupportingEvidenceIDs) > maxContentEvidencePerItem || + len(value.ContradictingEvidenceIDs) > maxContentEvidencePerItem || + !validUniqueAgentIDs(value.SupportingFactIDs) || + !validUniqueAgentIDs(value.SupportingEvidenceIDs) || + !validUniqueAgentIDs(value.ContradictingEvidenceIDs) { + return errors.New("content scout claim is invalid") + } + if value.Outcome != nil && + *value.Outcome != FactOutcomeSuccess && + *value.Outcome != FactOutcomeFailure && + *value.Outcome != FactOutcomeUnknown { + return errors.New("content scout claim outcome is invalid") + } + return nil +} + +func (value ContentScoutFactV1) Validate() error { + if !validAgentID(value.ID) || + len(value.Kind) == 0 || + len(value.Kind) > maxAgentNameBytes || + (value.Outcome != FactOutcomeNotApplicable && + value.Outcome != FactOutcomeSuccess && + value.Outcome != FactOutcomeFailure && + value.Outcome != FactOutcomeUnknown) || + len(value.EvidenceIDs) > maxContentEvidencePerItem || + !validUniqueAgentIDs(value.EvidenceIDs) || + value.Value.Validate() != nil { + return errors.New("content scout fact is invalid") + } + return nil +} + +func (value ContentScoutFactValueV1) Validate() error { + if value.Tool != nil { + if len(value.Tool.Kind) == 0 || len(value.Tool.Kind) > maxAgentNameBytes || + !validOptionalContentText(value.Tool.Name, maxContentTechnicalBytes) || + !validOptionalContentText(value.Tool.Namespace, maxContentTechnicalBytes) { + return errors.New("content scout tool value is invalid") + } + } + if !validSelectedText(value.Command) || !validSelectedText(value.Error) { + return errors.New("content scout selected text is invalid") + } + if value.Test != nil { + if len(value.Test.Framework) == 0 || + len(value.Test.Framework) > maxAgentNameBytes || + !validSelectedText(value.Test.Command) || + !validOptionalNonNegative(value.Test.Passed) || + !validOptionalNonNegative(value.Test.Failed) || + !validOptionalNonNegative(value.Test.Skipped) { + return errors.New("content scout test value is invalid") + } + } + return nil +} + +func (value ContentIdeaCandidateV1) Validate() error { + for _, field := range []string{ + value.Concept, + value.CoreLesson, + value.AudienceBenefit, + value.Hook, + value.Resonance, + } { + if len(field) == 0 || len(field) > maxContentIdeaTextBytes { + return errors.New("content idea text is invalid") + } + } + if !validConfidence(value.Confidence) || + len(value.ClaimIDs) == 0 || + len(value.ClaimIDs) > maxAgentKnowledgeClaims || + !validUniqueAgentIDs(value.ClaimIDs) { + return errors.New("content idea candidate is invalid") + } + for _, angle := range []ContentFormatAngleV1{value.ShortPost, value.Thread, value.Article} { + if len(angle.Angle) > maxContentIdeaTextBytes || + angle.Suitable && len(angle.Angle) == 0 { + return errors.New("content idea format angle is invalid") + } + } + return nil +} + +func (value ContentScoutCandidatesV1) Validate() error { + if len(value.Ideas) > maxAgentArtifacts { + return errors.New("content scout candidate count is invalid") + } + seen := make(map[string]struct{}, len(value.Ideas)) + for _, idea := range value.Ideas { + if idea.Validate() != nil { + return errors.New("content scout candidate is invalid") + } + fingerprint, err := platform.Fingerprint(idea) + if err != nil { + return errors.New("content scout candidate is invalid") + } + if _, duplicate := seen[fingerprint]; duplicate { + return errors.New("content scout candidate is duplicated") + } + seen[fingerprint] = struct{}{} + } + return nil +} + +func (value ContentIdeaV1) Validate() error { + if value.Rank < 1 || value.Rank > maxAgentArtifacts { + return errors.New("content idea rank is invalid") + } + return ContentIdeaCandidateV1{ + Concept: value.Concept, CoreLesson: value.CoreLesson, + AudienceBenefit: value.AudienceBenefit, Hook: value.Hook, + Resonance: value.Resonance, Confidence: value.Confidence, + ShortPost: value.ShortPost, Thread: value.Thread, Article: value.Article, + ClaimIDs: value.ClaimIDs, + }.Validate() +} + +func (value Artifact) Validate() error { + if !validAgentID(value.ID) || + !agentDigestPattern.MatchString(value.Fingerprint) || + value.ID != platform.DerivedID("artifact_", value.Fingerprint) || + !validSafeAgentValue(value.Kind) || + value.SchemaVersion < 1 || + !validAgentID(value.RunID) || + !validAgentID(value.TriggerEventID) || + !agentDigestPattern.MatchString(value.JobFingerprint) || + value.Inputs.Validate() != nil || + !validUniqueAgentIDs(value.ClaimIDs) || + !validUniqueAgentIDs(value.FactIDs) || + !validEvidenceIDs(value.SupportingEvidence) || + !validEvidenceIDs(value.ContradictingEvidence) || + !validSafeAgentValue(value.ProposalStatus) || + !validSafeAgentValue(value.SafetyStatus) || + value.CreatedAt.IsZero() { + return errors.New("artifact is invalid") + } + canonical, err := compactJSONObject(value.PayloadJSON, maxAgentPayloadBytes, 64) + if err != nil || !bytes.Equal(canonical, value.PayloadJSON) { + return errors.New("artifact payload is invalid") + } + if value.Kind == ArtifactKindContentIdea { + if value.SchemaVersion != ContentIdeaSchemaVersion || + value.ProposalStatus != ArtifactProposalReviewRequired || + value.SafetyStatus != ArtifactSafetyReviewRequired || + len(value.ClaimIDs) == 0 || + len(value.FactIDs) == 0 || + len(value.SupportingEvidence) == 0 { + return errors.New("content idea artifact is invalid") + } + idea, err := decodeStrictJSON[ContentIdeaV1](value.PayloadJSON) + if err != nil || idea.Validate() != nil || !slices.Equal(idea.ClaimIDs, value.ClaimIDs) { + return errors.New("content idea artifact payload is invalid") + } + for _, claimID := range value.ClaimIDs { + if !slices.Contains(value.Inputs.ClaimIDs, claimID) { + return errors.New("content idea artifact claim is outside the job inputs") + } + } + expected, err := ContentIdeaArtifactFingerprint( + value.JobFingerprint, + idea, + value.FactIDs, + value.SupportingEvidence, + value.ContradictingEvidence, + value.SafetyStatus, + ) + if err != nil || expected != value.Fingerprint { + return errors.New("content idea artifact fingerprint mismatch") + } + } + return nil +} + +func ContentIdeaArtifactFingerprint( + jobFingerprint string, + idea ContentIdeaV1, + factIDs []string, + supportingEvidence []EvidenceRef, + contradictingEvidence []EvidenceRef, + safetyStatus string, +) (string, error) { + if !agentDigestPattern.MatchString(jobFingerprint) || + idea.Validate() != nil || + len(factIDs) == 0 || + !validUniqueAgentIDs(factIDs) || + !validEvidenceIDs(supportingEvidence) || + !validEvidenceIDs(contradictingEvidence) || + safetyStatus != ArtifactSafetyReviewRequired { + return "", errors.New("content idea artifact fingerprint input is invalid") + } + return platform.Fingerprint(struct { + Kind string + SchemaVersion int + JobFingerprint string + Concept string + CoreLesson string + AudienceBenefit string + Hook string + Resonance string + Confidence float64 + ShortPost ContentFormatAngleV1 + Thread ContentFormatAngleV1 + Article ContentFormatAngleV1 + ClaimIDs []string + FactIDs []string + SupportingEvidence []EvidenceRef + ContradictingEvidence []EvidenceRef + SafetyStatus string + }{ + Kind: ArtifactKindContentIdea, SchemaVersion: ContentIdeaSchemaVersion, + JobFingerprint: jobFingerprint, Concept: idea.Concept, + CoreLesson: idea.CoreLesson, AudienceBenefit: idea.AudienceBenefit, + Hook: idea.Hook, Resonance: idea.Resonance, Confidence: idea.Confidence, + ShortPost: idea.ShortPost, Thread: idea.Thread, Article: idea.Article, + ClaimIDs: idea.ClaimIDs, FactIDs: factIDs, + SupportingEvidence: supportingEvidence, + ContradictingEvidence: contradictingEvidence, + SafetyStatus: safetyStatus, + }) +} + +func validSelectedText(value *ContentScoutSelectedTextV1) bool { + return value == nil || + len(value.Text) > 0 && + len(value.Text) <= maxContentTextBytes && + value.EmittedUTF8Bytes == len([]byte(value.Text)) && + value.OriginalUTF8Bytes > 0 +} + +func validOptionalContentText(value *string, maximum int) bool { + return value == nil || len(*value) <= maximum +} + +func validOptionalNonNegative(value *int) bool { + return value == nil || *value >= 0 +} + +func validEvidenceIDs(values []EvidenceRef) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if !validAgentID(value.ID) { + return false + } + if _, duplicate := seen[value.ID]; duplicate { + return false + } + seen[value.ID] = struct{}{} + } + return true +} + +func decodeStrictJSON[T any](value []byte) (T, error) { + var decoded T + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return decoded, err + } + if err := decoder.Decode(&json.RawMessage{}); !errors.Is(err, io.EOF) { + return decoded, errors.New("json contains trailing data") + } + return decoded, nil +}