diff --git a/cmd/gait/mcp.go b/cmd/gait/mcp.go index d0cc160..447da17 100644 --- a/cmd/gait/mcp.go +++ b/cmd/gait/mcp.go @@ -16,35 +16,40 @@ import ( "github.com/Clyra-AI/gait/core/mcp" "github.com/Clyra-AI/gait/core/pack" "github.com/Clyra-AI/gait/core/runpack" + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" sign "github.com/Clyra-AI/proof/signing" ) type mcpProxyOutput struct { - OK bool `json:"ok"` - Executed bool `json:"executed"` - Adapter string `json:"adapter,omitempty"` - RunID string `json:"run_id,omitempty"` - JobID string `json:"job_id,omitempty"` - Phase string `json:"phase,omitempty"` - SessionID string `json:"session_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - Verdict string `json:"verdict,omitempty"` - ReasonCodes []string `json:"reason_codes,omitempty"` - Violations []string `json:"violations,omitempty"` - PolicyDigest string `json:"policy_digest,omitempty"` - IntentDigest string `json:"intent_digest,omitempty"` - DecisionLatencyMS int64 `json:"decision_latency_ms,omitempty"` - TraceID string `json:"trace_id,omitempty"` - TracePath string `json:"trace_path,omitempty"` - RunpackPath string `json:"runpack_path,omitempty"` - PackPath string `json:"pack_path,omitempty"` - PackID string `json:"pack_id,omitempty"` - LogExport string `json:"log_export,omitempty"` - OTelExport string `json:"otel_export,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Error string `json:"error,omitempty"` + OK bool `json:"ok"` + Executed bool `json:"executed"` + Adapter string `json:"adapter,omitempty"` + RunID string `json:"run_id,omitempty"` + JobID string `json:"job_id,omitempty"` + Phase string `json:"phase,omitempty"` + SessionID string `json:"session_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Verdict string `json:"verdict,omitempty"` + ReasonCodes []string `json:"reason_codes,omitempty"` + Violations []string `json:"violations,omitempty"` + PolicyDigest string `json:"policy_digest,omitempty"` + PolicyID string `json:"policy_id,omitempty"` + PolicyVersion string `json:"policy_version,omitempty"` + MatchedRuleIDs []string `json:"matched_rule_ids,omitempty"` + IntentDigest string `json:"intent_digest,omitempty"` + DecisionLatencyMS int64 `json:"decision_latency_ms,omitempty"` + TraceID string `json:"trace_id,omitempty"` + TracePath string `json:"trace_path,omitempty"` + RunpackPath string `json:"runpack_path,omitempty"` + PackPath string `json:"pack_path,omitempty"` + PackID string `json:"pack_id,omitempty"` + LogExport string `json:"log_export,omitempty"` + OTelExport string `json:"otel_export,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + Error string `json:"error,omitempty"` } type mcpProxyEvalOptions struct { @@ -253,9 +258,15 @@ func evaluateMCPProxyPayload(policyPath string, payload []byte, options mcpProxy resolvedTracePath = fmt.Sprintf("trace_%s_%s.json", normalizeRunID(options.RunID), time.Now().UTC().Format("20060102T150405.000000000")) } traceResult, err := gate.EmitSignedTrace(policy, evalResult.Intent, evalResult.Outcome.Result, gate.EmitTraceOptions{ - ProducerVersion: version, - SigningPrivateKey: keyPair.Private, - TracePath: resolvedTracePath, + ProducerVersion: version, + ContextSource: evalResult.Outcome.ContextSource, + CompositeRiskClass: evalResult.Outcome.CompositeRiskClass, + StepVerdicts: evalResult.Outcome.StepVerdicts, + PreApproved: evalResult.Outcome.PreApproved, + PatternID: evalResult.Outcome.PatternID, + RegistryReason: evalResult.Outcome.RegistryReason, + SigningPrivateKey: keyPair.Private, + TracePath: resolvedTracePath, }) if err != nil { return mcpProxyOutput{}, exitInvalidInput, err @@ -376,6 +387,9 @@ func evaluateMCPProxyPayload(policyPath string, payload []byte, options mcpProxy ReasonCodes: evalResult.Outcome.Result.ReasonCodes, Violations: evalResult.Outcome.Result.Violations, PolicyDigest: traceResult.PolicyDigest, + PolicyID: traceResult.Trace.PolicyID, + PolicyVersion: traceResult.Trace.PolicyVersion, + MatchedRuleIDs: append([]string(nil), traceResult.Trace.MatchedRuleIDs...), IntentDigest: traceResult.IntentDigest, DecisionLatencyMS: decisionLatencyMS, TraceID: traceResult.Trace.TraceID, @@ -386,6 +400,7 @@ func evaluateMCPProxyPayload(policyPath string, payload []byte, options mcpProxy LogExport: resolvedLogExport, OTelExport: resolvedOTelExport, Warnings: warnings, + Relationship: traceResult.Trace.Relationship, }, exitCode, nil } diff --git a/cmd/gait/mcp_server.go b/cmd/gait/mcp_server.go index b7be26e..7479deb 100644 --- a/cmd/gait/mcp_server.go +++ b/cmd/gait/mcp_server.go @@ -18,6 +18,7 @@ import ( "github.com/Clyra-AI/gait/core/jobruntime" "github.com/Clyra-AI/gait/core/runpack" + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" ) type mcpServeConfig struct { @@ -502,16 +503,25 @@ func evaluateMCPServeRequest(config mcpServeConfig, writer http.ResponseWriter, safetyInvariantHash = strings.TrimSpace(state.SafetyInvariantHash) } } + var agentChain []schemacommon.AgentLink + if output.Relationship != nil { + agentChain = append(agentChain, output.Relationship.AgentChain...) + } event, err := runpack.AppendSessionEvent(journalPath, runpack.SessionAppendOptions{ - ProducerVersion: version, - ToolName: output.ToolName, - IntentDigest: output.IntentDigest, - PolicyDigest: output.PolicyDigest, - TraceID: output.TraceID, - TracePath: output.TracePath, - Verdict: output.Verdict, - ReasonCodes: output.ReasonCodes, - Violations: output.Violations, + ProducerVersion: version, + ToolName: output.ToolName, + IntentDigest: output.IntentDigest, + PolicyDigest: output.PolicyDigest, + PolicyID: output.PolicyID, + PolicyVersion: output.PolicyVersion, + MatchedRuleIDs: output.MatchedRuleIDs, + TraceID: output.TraceID, + TracePath: output.TracePath, + AgentChain: agentChain, + ActorIdentity: mcpServeRelationshipCallActor(output.Relationship, output.ToolName), + Verdict: output.Verdict, + ReasonCodes: output.ReasonCodes, + Violations: output.Violations, SafetyInvariantVersion: safetyInvariantVersion, SafetyInvariantHash: safetyInvariantHash, }) @@ -721,6 +731,28 @@ func sanitizeSessionFileBase(value string) string { return strings.Trim(mapped, "._") } +func mcpServeRelationshipCallActor(relationship *schemacommon.RelationshipEnvelope, toolName string) string { + if relationship == nil { + return "" + } + normalizedTool := strings.TrimSpace(toolName) + for _, edge := range relationship.Edges { + if strings.TrimSpace(edge.Kind) != "calls" { + continue + } + if strings.TrimSpace(edge.From.Kind) != "agent" || strings.TrimSpace(edge.To.Kind) != "tool" { + continue + } + if normalizedTool != "" && strings.TrimSpace(edge.To.ID) != normalizedTool { + continue + } + if actor := strings.TrimSpace(edge.From.ID); actor != "" { + return actor + } + } + return "" +} + func mcpServeErrorStatus(err error) int { var requestErr mcpServeRequestError if errors.As(err, &requestErr) { diff --git a/cmd/gait/voice.go b/cmd/gait/voice.go index dc79ec2..d4e0647 100644 --- a/cmd/gait/voice.go +++ b/cmd/gait/voice.go @@ -175,10 +175,16 @@ func runVoiceTokenMint(arguments []string) int { return writeVoiceTokenOutput(jsonOutput, voiceTokenOutput{OK: false, Operation: "mint", Error: err.Error()}, exitCodeForError(err, exitInvalidInput)) } traceResult, traceErr := gate.EmitSignedTrace(policy, normalizedIntent, outcome.Result, gate.EmitTraceOptions{ - ProducerVersion: version, - CorrelationID: currentCorrelationID(), - SigningPrivateKey: keyPair.Private, - TracePath: strings.TrimSpace(tracePath), + ProducerVersion: version, + CorrelationID: currentCorrelationID(), + ContextSource: outcome.ContextSource, + CompositeRiskClass: outcome.CompositeRiskClass, + StepVerdicts: outcome.StepVerdicts, + PreApproved: outcome.PreApproved, + PatternID: outcome.PatternID, + RegistryReason: outcome.RegistryReason, + SigningPrivateKey: keyPair.Private, + TracePath: strings.TrimSpace(tracePath), }) if traceErr != nil { return writeVoiceTokenOutput(jsonOutput, voiceTokenOutput{OK: false, Operation: "mint", Error: traceErr.Error()}, exitCodeForError(traceErr, exitInvalidInput)) diff --git a/core/doctor/doctor.go b/core/doctor/doctor.go index ac9368e..da0cff5 100644 --- a/core/doctor/doctor.go +++ b/core/doctor/doctor.go @@ -63,6 +63,7 @@ var requiredSchemaPaths = []string{ "schemas/v1/gate/approval_audit_record.schema.json", "schemas/v1/gate/broker_credential_record.schema.json", "schemas/v1/gate/approved_script_entry.schema.json", + "schemas/v1/common/relationship_envelope.schema.json", "schemas/v1/context/envelope.schema.json", "schemas/v1/context/reference_record.schema.json", "schemas/v1/context/budget_report.schema.json", diff --git a/core/gate/approval_audit.go b/core/gate/approval_audit.go index abdfaf5..6c9086d 100644 --- a/core/gate/approval_audit.go +++ b/core/gate/approval_audit.go @@ -92,6 +92,7 @@ func BuildApprovalAuditRecord(opts BuildApprovalAuditOptions) schemagate.Approva ValidApprovals: validApprovals, Approved: validApprovals >= requiredApprovals, Approvers: uniqueSorted(approvers), + Relationship: buildApprovalAuditRelationship(opts.TraceID, opts.ToolName, opts.PolicyDigest, approvers), Entries: entries, } } diff --git a/core/gate/approval_audit_test.go b/core/gate/approval_audit_test.go index 5ddf2df..16cf25b 100644 --- a/core/gate/approval_audit_test.go +++ b/core/gate/approval_audit_test.go @@ -49,6 +49,21 @@ func TestBuildApprovalAuditRecordDeterministic(t *testing.T) { if len(record.Entries) != 2 || record.Entries[0].TokenID != "token_a" { t.Fatalf("expected sorted entries, got %#v", record.Entries) } + if record.Relationship == nil { + t.Fatalf("expected relationship envelope in approval audit record") + } + if record.Relationship.ParentRef == nil || record.Relationship.ParentRef.Kind != "trace" || record.Relationship.ParentRef.ID != "trace_1" { + t.Fatalf("unexpected relationship parent_ref: %#v", record.Relationship.ParentRef) + } + if record.Relationship.PolicyRef == nil || record.Relationship.PolicyRef.PolicyDigest != record.PolicyDigest { + t.Fatalf("expected policy_ref digest in relationship: %#v", record.Relationship.PolicyRef) + } + if len(record.Relationship.AgentChain) != 0 { + t.Fatalf("expected no approver agent chain role in relationship: %#v", record.Relationship.AgentChain) + } + if len(record.Relationship.Edges) != 1 || record.Relationship.Edges[0].Kind != "governed_by" { + t.Fatalf("expected governed_by relationship edge in approval audit record: %#v", record.Relationship.Edges) + } } func TestWriteApprovalAuditRecord(t *testing.T) { diff --git a/core/gate/delegation_audit.go b/core/gate/delegation_audit.go index 96b3da1..0b164de 100644 --- a/core/gate/delegation_audit.go +++ b/core/gate/delegation_audit.go @@ -67,6 +67,7 @@ func BuildDelegationAuditRecord(opts BuildDelegationAuditOptions) schemagate.Del ValidDelegations: validDelegations, Delegated: validDelegations > 0, DelegationRef: strings.TrimSpace(opts.DelegationRef), + Relationship: buildDelegationAuditRelationship(opts.TraceID, opts.ToolName, opts.PolicyDigest, entries), Entries: entries, } } diff --git a/core/gate/delegation_test.go b/core/gate/delegation_test.go index 6dc4a11..b94bb13 100644 --- a/core/gate/delegation_test.go +++ b/core/gate/delegation_test.go @@ -303,6 +303,18 @@ func TestDelegationAuditRecordBuildAndWrite(t *testing.T) { if len(record.Entries) != 2 || record.Entries[0].DelegatorIdentity != "agent.a" { t.Fatalf("expected sorted delegation audit entries, got %#v", record.Entries) } + if record.Relationship == nil { + t.Fatalf("expected relationship envelope in delegation audit record") + } + if record.Relationship.ParentRef == nil || record.Relationship.ParentRef.Kind != "trace" || record.Relationship.ParentRef.ID != "trace_demo" { + t.Fatalf("unexpected relationship parent_ref: %#v", record.Relationship.ParentRef) + } + if record.Relationship.PolicyRef == nil || record.Relationship.PolicyRef.PolicyDigest != record.PolicyDigest { + t.Fatalf("expected policy_ref digest in relationship: %#v", record.Relationship.PolicyRef) + } + if len(record.Relationship.Edges) == 0 { + t.Fatalf("expected relationship edges in delegation audit record") + } workDir := t.TempDir() path := filepath.Join(workDir, "audit", "delegation_audit.json") diff --git a/core/gate/intent.go b/core/gate/intent.go index fabc8a7..8371c2d 100644 --- a/core/gate/intent.go +++ b/core/gate/intent.go @@ -119,6 +119,7 @@ func NormalizeIntent(input schemagate.IntentRequest) (schemagate.IntentRequest, output.ArgProvenance = normalized.ArgProvenance output.SkillProvenance = normalized.SkillProvenance output.Delegation = normalized.Delegation + output.Relationship = normalizeRelationshipEnvelope(input.Relationship) output.Context = normalized.Context return output, nil } diff --git a/core/gate/relationship.go b/core/gate/relationship.go new file mode 100644 index 0000000..e3d6aae --- /dev/null +++ b/core/gate/relationship.go @@ -0,0 +1,484 @@ +package gate + +import ( + "sort" + "strings" + + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" +) + +var ( + allowedRelationshipParentKinds = map[string]struct{}{ + "trace": {}, + "run": {}, + "session": {}, + "intent": {}, + "policy": {}, + "agent": {}, + "evidence": {}, + } + allowedRelationshipEntityKinds = map[string]struct{}{ + "agent": {}, + "tool": {}, + "resource": {}, + "policy": {}, + "run": {}, + "trace": {}, + "delegation": {}, + "evidence": {}, + } + allowedRelationshipAgentRoles = map[string]struct{}{ + "requester": {}, + "delegator": {}, + "delegate": {}, + } + allowedRelationshipEdgeKinds = map[string]struct{}{ + "delegates_to": {}, + "calls": {}, + "governed_by": {}, + "targets": {}, + "derived_from": {}, + "emits_evidence": {}, + } +) + +func buildTraceRelationship( + intent schemagate.IntentRequest, + traceID string, + policyID string, + policyVersion string, + policyDigest string, + matchedRuleIDs []string, +) *schemacommon.RelationshipEnvelope { + relationship := schemacommon.RelationshipEnvelope{} + if parentKind, parentID := parentRefFromIntent(intent.Context); parentID != "" { + relationship.ParentRef = &schemacommon.RelationshipNodeRef{Kind: parentKind, ID: parentID} + } + + entityRefs := []schemacommon.RelationshipRef{} + if traceID = strings.TrimSpace(traceID); traceID != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "trace", ID: traceID}) + } + if toolName := strings.TrimSpace(intent.ToolName); toolName != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "tool", ID: toolName}) + } + if identity := strings.TrimSpace(intent.Context.Identity); identity != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "agent", ID: identity}) + } + if policyDigest = strings.ToLower(strings.TrimSpace(policyDigest)); policyDigest != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "policy", ID: policyDigest}) + } + relationship.EntityRefs = normalizeRelationshipRefs(entityRefs) + relationship.RelatedEntityIDs = relationshipRefIDs(relationship.EntityRefs) + + policyID = strings.TrimSpace(policyID) + policyVersion = strings.TrimSpace(policyVersion) + matchedRuleIDs = uniqueSorted(matchedRuleIDs) + if policyID != "" || policyVersion != "" || policyDigest != "" || len(matchedRuleIDs) > 0 { + relationship.PolicyRef = &schemacommon.PolicyRef{ + PolicyID: policyID, + PolicyVersion: policyVersion, + PolicyDigest: policyDigest, + MatchedRuleIDs: matchedRuleIDs, + } + } + + agentChain := []schemacommon.AgentLink{} + if intent.Delegation != nil { + if requester := strings.TrimSpace(intent.Delegation.RequesterIdentity); requester != "" { + agentChain = append(agentChain, schemacommon.AgentLink{Identity: requester, Role: "requester"}) + } + for _, link := range intent.Delegation.Chain { + if delegator := strings.TrimSpace(link.DelegatorIdentity); delegator != "" { + agentChain = append(agentChain, schemacommon.AgentLink{Identity: delegator, Role: "delegator"}) + } + if delegate := strings.TrimSpace(link.DelegateIdentity); delegate != "" { + agentChain = append(agentChain, schemacommon.AgentLink{Identity: delegate, Role: "delegate"}) + } + } + } else if identity := strings.TrimSpace(intent.Context.Identity); identity != "" { + agentChain = append(agentChain, schemacommon.AgentLink{Identity: identity, Role: "requester"}) + } + relationship.AgentChain = normalizeAgentChain(agentChain) + + edges := []schemacommon.RelationshipEdge{} + if actor := strings.TrimSpace(intent.Context.Identity); actor != "" && strings.TrimSpace(intent.ToolName) != "" { + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "calls", + From: schemacommon.RelationshipNodeRef{Kind: "agent", ID: actor}, + To: schemacommon.RelationshipNodeRef{Kind: "tool", ID: strings.TrimSpace(intent.ToolName)}, + }) + } + if strings.TrimSpace(intent.ToolName) != "" && policyDigest != "" { + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "governed_by", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: strings.TrimSpace(intent.ToolName)}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: policyDigest}, + }) + } + if intent.Delegation != nil { + for _, link := range intent.Delegation.Chain { + delegator := strings.TrimSpace(link.DelegatorIdentity) + delegate := strings.TrimSpace(link.DelegateIdentity) + if delegator == "" || delegate == "" { + continue + } + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "delegates_to", + From: schemacommon.RelationshipNodeRef{Kind: "agent", ID: delegator}, + To: schemacommon.RelationshipNodeRef{Kind: "agent", ID: delegate}, + }) + } + } + relationship.Edges = normalizeRelationshipEdges(edges) + return normalizeRelationshipEnvelope(&relationship) +} + +func buildApprovalAuditRelationship(traceID, toolName, policyDigest string, approvers []string) *schemacommon.RelationshipEnvelope { + traceID = strings.TrimSpace(traceID) + relationship := schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{ + Kind: "trace", + ID: traceID, + }, + ParentRecordID: traceID, + } + if relationship.ParentRef.ID == "" { + relationship.ParentRef = nil + } + toolName = strings.TrimSpace(toolName) + policyDigest = strings.ToLower(strings.TrimSpace(policyDigest)) + + entityRefs := []schemacommon.RelationshipRef{} + if traceID != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "trace", ID: traceID}) + } + if toolName != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "tool", ID: toolName}) + } + if policyDigest != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "policy", ID: policyDigest}) + relationship.PolicyRef = &schemacommon.PolicyRef{PolicyDigest: policyDigest} + } + approvers = uniqueSorted(approvers) + for _, approver := range approvers { + if approver = strings.TrimSpace(approver); approver != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "agent", ID: approver}) + } + } + relationship.EntityRefs = normalizeRelationshipRefs(entityRefs) + relationship.RelatedEntityIDs = relationshipRefIDs(relationship.EntityRefs) + + edges := []schemacommon.RelationshipEdge{} + if toolName != "" && policyDigest != "" { + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "governed_by", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: toolName}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: policyDigest}, + }) + } + relationship.Edges = normalizeRelationshipEdges(edges) + return normalizeRelationshipEnvelope(&relationship) +} + +func buildDelegationAuditRelationship(traceID, toolName, policyDigest string, entries []schemagate.DelegationAuditEntry) *schemacommon.RelationshipEnvelope { + traceID = strings.TrimSpace(traceID) + relationship := schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{ + Kind: "trace", + ID: traceID, + }, + ParentRecordID: traceID, + } + if relationship.ParentRef.ID == "" { + relationship.ParentRef = nil + } + toolName = strings.TrimSpace(toolName) + policyDigest = strings.ToLower(strings.TrimSpace(policyDigest)) + + entityRefs := []schemacommon.RelationshipRef{} + if traceID != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "trace", ID: traceID}) + } + if toolName != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "tool", ID: toolName}) + } + if policyDigest != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "policy", ID: policyDigest}) + relationship.PolicyRef = &schemacommon.PolicyRef{PolicyDigest: policyDigest} + } + + agentChain := []schemacommon.AgentLink{} + edges := []schemacommon.RelationshipEdge{} + for _, entry := range entries { + delegator := strings.TrimSpace(entry.DelegatorIdentity) + delegate := strings.TrimSpace(entry.DelegateIdentity) + if delegator != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "agent", ID: delegator}) + agentChain = append(agentChain, schemacommon.AgentLink{Identity: delegator, Role: "delegator"}) + } + if delegate != "" { + entityRefs = append(entityRefs, schemacommon.RelationshipRef{Kind: "agent", ID: delegate}) + agentChain = append(agentChain, schemacommon.AgentLink{Identity: delegate, Role: "delegate"}) + } + if delegator != "" && delegate != "" { + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "delegates_to", + From: schemacommon.RelationshipNodeRef{Kind: "agent", ID: delegator}, + To: schemacommon.RelationshipNodeRef{Kind: "agent", ID: delegate}, + }) + } + } + if toolName != "" && policyDigest != "" { + edges = append(edges, schemacommon.RelationshipEdge{ + Kind: "governed_by", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: toolName}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: policyDigest}, + }) + } + + relationship.EntityRefs = normalizeRelationshipRefs(entityRefs) + relationship.RelatedEntityIDs = relationshipRefIDs(relationship.EntityRefs) + relationship.AgentChain = normalizeAgentChain(agentChain) + relationship.Edges = normalizeRelationshipEdges(edges) + return normalizeRelationshipEnvelope(&relationship) +} + +func normalizeRelationshipEnvelope(envelope *schemacommon.RelationshipEnvelope) *schemacommon.RelationshipEnvelope { + if envelope == nil { + return nil + } + normalized := *envelope + normalized.ParentRecordID = strings.TrimSpace(normalized.ParentRecordID) + normalized.RelatedRecordIDs = uniqueSorted(normalized.RelatedRecordIDs) + normalized.RelatedEntityIDs = uniqueSorted(normalized.RelatedEntityIDs) + normalized.ParentRef = normalizeParentRef(normalized.ParentRef) + normalized.EntityRefs = normalizeRelationshipRefs(normalized.EntityRefs) + normalized.AgentChain = normalizeAgentChain(normalized.AgentChain) + normalized.Edges = normalizeRelationshipEdges(normalized.Edges) + normalized.AgentLineage = normalizeAgentLineage(normalized.AgentLineage) + if normalized.PolicyRef != nil { + normalized.PolicyRef.PolicyID = strings.TrimSpace(normalized.PolicyRef.PolicyID) + normalized.PolicyRef.PolicyVersion = strings.TrimSpace(normalized.PolicyRef.PolicyVersion) + normalized.PolicyRef.PolicyDigest = strings.ToLower(strings.TrimSpace(normalized.PolicyRef.PolicyDigest)) + normalized.PolicyRef.MatchedRuleIDs = uniqueSorted(normalized.PolicyRef.MatchedRuleIDs) + if normalized.PolicyRef.PolicyID == "" && + normalized.PolicyRef.PolicyVersion == "" && + normalized.PolicyRef.PolicyDigest == "" && + len(normalized.PolicyRef.MatchedRuleIDs) == 0 { + normalized.PolicyRef = nil + } + } + if len(normalized.RelatedEntityIDs) == 0 { + normalized.RelatedEntityIDs = relationshipRefIDs(normalized.EntityRefs) + } + if isRelationshipEnvelopeEmpty(normalized) { + return nil + } + return &normalized +} + +func normalizeParentRef(ref *schemacommon.RelationshipNodeRef) *schemacommon.RelationshipNodeRef { + if ref == nil { + return nil + } + kind := strings.ToLower(strings.TrimSpace(ref.Kind)) + id := strings.TrimSpace(ref.ID) + if kind == "" || id == "" { + return nil + } + if _, ok := allowedRelationshipParentKinds[kind]; !ok { + return nil + } + return &schemacommon.RelationshipNodeRef{Kind: kind, ID: id} +} + +func normalizeAgentLineage(values []schemacommon.AgentLineage) []schemacommon.AgentLineage { + if len(values) == 0 { + return nil + } + out := make([]schemacommon.AgentLineage, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + agentID := strings.TrimSpace(value.AgentID) + delegatedBy := strings.TrimSpace(value.DelegatedBy) + delegationRecordID := strings.TrimSpace(value.DelegationRecordID) + if agentID == "" { + continue + } + key := agentID + "\x00" + delegatedBy + "\x00" + delegationRecordID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, schemacommon.AgentLineage{ + AgentID: agentID, + DelegatedBy: delegatedBy, + DelegationRecordID: delegationRecordID, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].AgentID != out[j].AgentID { + return out[i].AgentID < out[j].AgentID + } + if out[i].DelegatedBy != out[j].DelegatedBy { + return out[i].DelegatedBy < out[j].DelegatedBy + } + return out[i].DelegationRecordID < out[j].DelegationRecordID + }) + if len(out) == 0 { + return nil + } + return out +} + +func relationshipRefIDs(refs []schemacommon.RelationshipRef) []string { + if len(refs) == 0 { + return nil + } + ids := make([]string, 0, len(refs)) + for _, ref := range refs { + if id := strings.TrimSpace(ref.ID); id != "" { + ids = append(ids, id) + } + } + return uniqueSorted(ids) +} + +func parentRefFromIntent(context schemagate.IntentContext) (string, string) { + if sessionID := strings.TrimSpace(context.SessionID); sessionID != "" { + return "session", sessionID + } + return "", "" +} + +func normalizeRelationshipRefs(refs []schemacommon.RelationshipRef) []schemacommon.RelationshipRef { + if len(refs) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipRef, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + kind := strings.ToLower(strings.TrimSpace(ref.Kind)) + id := strings.TrimSpace(ref.ID) + if kind == "" || id == "" { + continue + } + if _, ok := allowedRelationshipEntityKinds[kind]; !ok { + continue + } + key := kind + "\x00" + id + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipRef{Kind: kind, ID: id}) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + return normalized[i].ID < normalized[j].ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeAgentChain(chain []schemacommon.AgentLink) []schemacommon.AgentLink { + if len(chain) == 0 { + return nil + } + normalized := make([]schemacommon.AgentLink, 0, len(chain)) + seen := map[string]struct{}{} + for _, link := range chain { + role := strings.ToLower(strings.TrimSpace(link.Role)) + identity := strings.TrimSpace(link.Identity) + if role == "" || identity == "" { + continue + } + if _, ok := allowedRelationshipAgentRoles[role]; !ok { + continue + } + key := role + "\x00" + identity + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.AgentLink{Role: role, Identity: identity}) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Role != normalized[j].Role { + return normalized[i].Role < normalized[j].Role + } + return normalized[i].Identity < normalized[j].Identity + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeRelationshipEdges(edges []schemacommon.RelationshipEdge) []schemacommon.RelationshipEdge { + if len(edges) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipEdge, 0, len(edges)) + seen := map[string]struct{}{} + for _, edge := range edges { + kind := strings.ToLower(strings.TrimSpace(edge.Kind)) + fromKind := strings.ToLower(strings.TrimSpace(edge.From.Kind)) + fromID := strings.TrimSpace(edge.From.ID) + toKind := strings.ToLower(strings.TrimSpace(edge.To.Kind)) + toID := strings.TrimSpace(edge.To.ID) + if kind == "" || fromKind == "" || fromID == "" || toKind == "" || toID == "" { + continue + } + if _, ok := allowedRelationshipEdgeKinds[kind]; !ok { + continue + } + key := kind + "\x00" + fromKind + "\x00" + fromID + "\x00" + toKind + "\x00" + toID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipEdge{ + Kind: kind, + From: schemacommon.RelationshipNodeRef{Kind: fromKind, ID: fromID}, + To: schemacommon.RelationshipNodeRef{Kind: toKind, ID: toID}, + }) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + if normalized[i].From.Kind != normalized[j].From.Kind { + return normalized[i].From.Kind < normalized[j].From.Kind + } + if normalized[i].From.ID != normalized[j].From.ID { + return normalized[i].From.ID < normalized[j].From.ID + } + if normalized[i].To.Kind != normalized[j].To.Kind { + return normalized[i].To.Kind < normalized[j].To.Kind + } + return normalized[i].To.ID < normalized[j].To.ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func isRelationshipEnvelopeEmpty(envelope schemacommon.RelationshipEnvelope) bool { + return envelope.ParentRef == nil && + len(envelope.EntityRefs) == 0 && + envelope.PolicyRef == nil && + len(envelope.AgentChain) == 0 && + len(envelope.Edges) == 0 && + strings.TrimSpace(envelope.ParentRecordID) == "" && + len(envelope.RelatedRecordIDs) == 0 && + len(envelope.RelatedEntityIDs) == 0 && + len(envelope.AgentLineage) == 0 +} diff --git a/core/gate/trace.go b/core/gate/trace.go index e804e80..94c4255 100644 --- a/core/gate/trace.go +++ b/core/gate/trace.go @@ -53,6 +53,8 @@ func EmitSignedTrace(policy Policy, intent schemagate.IntentRequest, gateResult if err != nil { return EmitTraceResult{}, fmt.Errorf("digest policy for trace: %w", err) } + policyID := strings.TrimSpace(policy.SchemaID) + policyVersion := strings.TrimSpace(policy.SchemaVersion) if normalizedIntent.IntentDigest == "" { return EmitTraceResult{}, fmt.Errorf("intent digest missing after normalization") } @@ -89,6 +91,8 @@ func EmitSignedTrace(policy Policy, intent schemagate.IntentRequest, gateResult ArgsDigest: normalizedIntent.ArgsDigest, IntentDigest: normalizedIntent.IntentDigest, PolicyDigest: policyDigest, + PolicyID: policyID, + PolicyVersion: policyVersion, Verdict: verdict, ContextSetDigest: normalizedIntent.Context.ContextSetDigest, ContextEvidenceMode: normalizedIntent.Context.ContextEvidenceMode, @@ -106,6 +110,15 @@ func EmitSignedTrace(policy Policy, intent schemagate.IntentRequest, gateResult ApprovalTokenRef: strings.TrimSpace(opts.ApprovalTokenRef), SkillProvenance: normalizedIntent.SkillProvenance, } + trace.MatchedRuleIDs = matchedRuleIDsFromStepVerdicts(opts.StepVerdicts) + trace.Relationship = buildTraceRelationship( + normalizedIntent, + trace.TraceID, + policyID, + policyVersion, + policyDigest, + trace.MatchedRuleIDs, + ) if normalizedIntent.Script != nil { trace.StepCount = len(normalizedIntent.Script.Steps) } @@ -257,6 +270,19 @@ func clampLatency(value float64) float64 { return value } +func matchedRuleIDsFromStepVerdicts(stepVerdicts []schemagate.TraceStepVerdict) []string { + if len(stepVerdicts) == 0 { + return nil + } + matched := make([]string, 0, len(stepVerdicts)) + for _, step := range stepVerdicts { + if ruleID := strings.TrimSpace(step.MatchedRule); ruleID != "" { + matched = append(matched, ruleID) + } + } + return uniqueSorted(matched) +} + func normalizeTracePath(path string) (string, error) { trimmed := strings.TrimSpace(path) if trimmed == "" { diff --git a/core/gate/trace_test.go b/core/gate/trace_test.go index 38ab5a0..07bdf28 100644 --- a/core/gate/trace_test.go +++ b/core/gate/trace_test.go @@ -355,6 +355,79 @@ func TestEmitSignedTraceRuntimeEventIdentity(t *testing.T) { } } +func TestEmitSignedTraceRelationshipEnvelope(t *testing.T) { + keyPair, err := sign.GenerateKeyPair() + if err != nil { + t.Fatalf("generate key pair: %v", err) + } + policy, err := ParsePolicyYAML([]byte(`default_verdict: allow`)) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + intent := baseIntent() + intent.ToolName = "tool.write" + intent.Context.Identity = "agent.requester" + intent.Context.Workspace = "/repo/gait" + intent.Context.SessionID = "sess_demo" + intent.Delegation = &schemagate.IntentDelegation{ + RequesterIdentity: "agent.requester", + Chain: []schemagate.DelegationLink{ + {DelegatorIdentity: "agent.lead", DelegateIdentity: "agent.worker"}, + }, + } + result, err := EvaluatePolicy(policy, intent, EvalOptions{ProducerVersion: "test"}) + if err != nil { + t.Fatalf("evaluate policy: %v", err) + } + + first, err := EmitSignedTrace(policy, intent, result, EmitTraceOptions{ + ProducerVersion: "test", + SigningPrivateKey: keyPair.Private, + TracePath: filepath.Join(t.TempDir(), "trace_relationship_1.json"), + StepVerdicts: []schemagate.TraceStepVerdict{ + {Index: 0, ToolName: "tool.write", Verdict: "allow", MatchedRule: "rule-z"}, + {Index: 1, ToolName: "tool.write", Verdict: "allow", MatchedRule: "rule-a"}, + {Index: 2, ToolName: "tool.write", Verdict: "allow", MatchedRule: "rule-a"}, + }, + }) + if err != nil { + t.Fatalf("emit first trace: %v", err) + } + if first.Trace.Relationship == nil { + t.Fatalf("expected relationship envelope in trace") + } + if first.Trace.Relationship.ParentRef == nil || first.Trace.Relationship.ParentRef.Kind != "session" || first.Trace.Relationship.ParentRef.ID != "sess_demo" { + t.Fatalf("unexpected parent_ref: %#v", first.Trace.Relationship.ParentRef) + } + if first.Trace.Relationship.PolicyRef == nil || len(first.Trace.Relationship.PolicyRef.MatchedRuleIDs) != 2 { + t.Fatalf("expected matched rule ids in relationship policy_ref: %#v", first.Trace.Relationship.PolicyRef) + } + if first.Trace.PolicyID != policy.SchemaID || first.Trace.PolicyVersion != policy.SchemaVersion { + t.Fatalf("expected policy lineage fields in trace: id=%q version=%q", first.Trace.PolicyID, first.Trace.PolicyVersion) + } + if len(first.Trace.MatchedRuleIDs) != 2 { + t.Fatalf("expected matched_rule_ids in trace: %#v", first.Trace.MatchedRuleIDs) + } + if first.Trace.Relationship.PolicyRef.MatchedRuleIDs[0] != "rule-a" || first.Trace.Relationship.PolicyRef.MatchedRuleIDs[1] != "rule-z" { + t.Fatalf("expected deterministic matched rule ordering, got %#v", first.Trace.Relationship.PolicyRef.MatchedRuleIDs) + } + if len(first.Trace.Relationship.EntityRefs) == 0 || len(first.Trace.Relationship.Edges) == 0 { + t.Fatalf("expected relationship entity refs and edges: %#v", first.Trace.Relationship) + } + + second, err := EmitSignedTrace(policy, intent, result, EmitTraceOptions{ + ProducerVersion: "test", + SigningPrivateKey: keyPair.Private, + TracePath: filepath.Join(t.TempDir(), "trace_relationship_2.json"), + }) + if err != nil { + t.Fatalf("emit second trace: %v", err) + } + if first.Trace.TraceID != second.Trace.TraceID { + t.Fatalf("trace_id must remain stable with/without relationship detail; first=%s second=%s", first.Trace.TraceID, second.Trace.TraceID) + } +} + func TestWriteTraceRecordRejectsParentTraversal(t *testing.T) { minimal := schemagate.TraceRecord{ SchemaID: "gait.gate.trace", diff --git a/core/gateway/ingest.go b/core/gateway/ingest.go index acbef9e..1e61957 100644 --- a/core/gateway/ingest.go +++ b/core/gateway/ingest.go @@ -196,17 +196,18 @@ func parseGatewayEvent(source string, line string, lineNo int) (gatewayEvent, er )) toolName := firstNonEmpty(extractString(payload, "tool_name", "tool", "route.name", "service.name", "request.path", "request.uri", "path"), "gateway.unknown") reasonCodes := extractReasonCodes(payload) + rawDigest := sha256Hex([]byte(line)) return gatewayEvent{ Timestamp: timestamp, ToolName: toolName, Verdict: verdict, - PolicyDigest: extractString(payload, "policy_digest", "policy.hash", "policy.id", "policy_version"), + PolicyDigest: resolvePolicyDigest(payload, rawDigest), ReasonCodes: reasonCodes, RequestID: extractString(payload, "request_id", "request.id", "correlation_id", "trace_id"), Identity: extractString(payload, "identity", "consumer.username", "user", "actor"), Path: extractString(payload, "path", "request.path", "request.uri"), StatusCode: statusCode, - RawDigest: sha256Hex([]byte(line)), + RawDigest: rawDigest, }, nil } @@ -494,3 +495,13 @@ func nonEmptyOrDefault(value string, fallback string) string { } return trimmed } + +func resolvePolicyDigest(payload map[string]any, fallbackDigest string) string { + candidate := strings.TrimSpace(extractString(payload, "policy_digest", "policy.hash", "policy.id", "policy_version")) + if candidate != "" { + return candidate + } + // proof v0.4.5 requires non-empty policy_digest for policy_enforcement records. + // Use deterministic source-event digest when upstream policy metadata is absent. + return strings.TrimSpace(fallbackDigest) +} diff --git a/core/gateway/ingest_test.go b/core/gateway/ingest_test.go index e6186f2..5c4f433 100644 --- a/core/gateway/ingest_test.go +++ b/core/gateway/ingest_test.go @@ -66,6 +66,10 @@ func TestIngestLogsKongProducesSignedPolicyEnforcementRecords(t *testing.T) { if strings.TrimSpace(recordItem.Integrity.Signature) == "" || strings.TrimSpace(recordItem.Integrity.SigningKeyID) == "" { t.Fatalf("expected signed record integrity payload, got %#v", recordItem.Integrity) } + policyDigest, _ := recordItem.Event["policy_digest"].(string) + if strings.TrimSpace(policyDigest) == "" { + t.Fatalf("expected non-empty policy_digest in event payload, got %#v", recordItem.Event) + } ok, verifyErr := sign.VerifyBytes(keyPair.Public, sign.Signature{ Alg: sign.AlgEd25519, KeyID: recordItem.Integrity.SigningKeyID, diff --git a/core/runpack/session.go b/core/runpack/session.go index 324283b..d3ffde5 100644 --- a/core/runpack/session.go +++ b/core/runpack/session.go @@ -18,6 +18,7 @@ import ( "time" "github.com/Clyra-AI/gait/core/fsx" + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" jcs "github.com/Clyra-AI/proof/canon" ) @@ -48,17 +49,22 @@ type SessionStartOptions struct { } type SessionAppendOptions struct { - CreatedAt time.Time - ProducerVersion string - IntentID string - ToolName string - IntentDigest string - PolicyDigest string - TraceID string - TracePath string - Verdict string - ReasonCodes []string - Violations []string + CreatedAt time.Time + ProducerVersion string + IntentID string + ToolName string + IntentDigest string + PolicyDigest string + PolicyID string + PolicyVersion string + MatchedRuleIDs []string + TraceID string + TracePath string + AgentChain []schemacommon.AgentLink + ActorIdentity string + Verdict string + ReasonCodes []string + Violations []string SafetyInvariantVersion string SafetyInvariantHash string } @@ -312,23 +318,34 @@ func AppendSessionEvent(path string, opts SessionAppendOptions) (schemarunpack.S producerVersion = "0.0.0-dev" } sequence := state.LastSequence + 1 + toolName := strings.TrimSpace(opts.ToolName) + intentDigest := strings.ToLower(strings.TrimSpace(opts.IntentDigest)) + policyDigest := strings.ToLower(strings.TrimSpace(opts.PolicyDigest)) + policyID := strings.TrimSpace(opts.PolicyID) + policyVersion := strings.TrimSpace(opts.PolicyVersion) + matchedRuleIDs := uniqueSortedStrings(opts.MatchedRuleIDs) + traceID := strings.TrimSpace(opts.TraceID) appended = schemarunpack.SessionEvent{ - SchemaID: sessionEventSchemaID, - SchemaVersion: sessionEventSchemaVersion, - CreatedAt: now, - ProducerVersion: producerVersion, - SessionID: state.SessionID, - RunID: state.RunID, - Sequence: sequence, - IntentID: strings.TrimSpace(opts.IntentID), - ToolName: strings.TrimSpace(opts.ToolName), - IntentDigest: strings.ToLower(strings.TrimSpace(opts.IntentDigest)), - PolicyDigest: strings.ToLower(strings.TrimSpace(opts.PolicyDigest)), - TraceID: strings.TrimSpace(opts.TraceID), - TracePath: strings.TrimSpace(opts.TracePath), - Verdict: strings.TrimSpace(opts.Verdict), - ReasonCodes: uniqueSortedStrings(opts.ReasonCodes), - Violations: uniqueSortedStrings(opts.Violations), + SchemaID: sessionEventSchemaID, + SchemaVersion: sessionEventSchemaVersion, + CreatedAt: now, + ProducerVersion: producerVersion, + SessionID: state.SessionID, + RunID: state.RunID, + Sequence: sequence, + IntentID: strings.TrimSpace(opts.IntentID), + ToolName: toolName, + IntentDigest: intentDigest, + PolicyDigest: policyDigest, + PolicyID: policyID, + PolicyVersion: policyVersion, + MatchedRuleIDs: matchedRuleIDs, + TraceID: traceID, + TracePath: strings.TrimSpace(opts.TracePath), + Verdict: strings.TrimSpace(opts.Verdict), + ReasonCodes: uniqueSortedStrings(opts.ReasonCodes), + Violations: uniqueSortedStrings(opts.Violations), + Relationship: buildSessionEventRelationship(state.SessionID, state.RunID, toolName, traceID, policyID, policyVersion, policyDigest, matchedRuleIDs, opts.ActorIdentity, opts.AgentChain), SafetyInvariantVersion: strings.TrimSpace(opts.SafetyInvariantVersion), SafetyInvariantHash: strings.ToLower(strings.TrimSpace(opts.SafetyInvariantHash)), } @@ -486,12 +503,29 @@ func EmitSessionCheckpoint(journalPath string, outRunpackPath string, opts Sessi Event: "session_event", TS: event.CreatedAt.UTC(), Ref: event.TraceID, + Relationship: buildRunTimelineRelationship( + "session_event", + checkpointRunID, + journal.SessionID, + event.TraceID, + event.ToolName, + event.PolicyDigest, + ), }) } + checkpointRef := fmt.Sprintf("checkpoint:%d", nextCheckpointIdx) timeline = append(timeline, schemarunpack.TimelineEvt{ Event: "session_checkpoint_emitted", TS: createdAt, - Ref: fmt.Sprintf("checkpoint:%d", nextCheckpointIdx), + Ref: checkpointRef, + Relationship: buildRunTimelineRelationship( + "session_checkpoint_emitted", + checkpointRunID, + checkpointRef, + "", + "", + "", + ), }) recordRes, writeErr := WriteRunpack(runpackPath, RecordOptions{ @@ -523,19 +557,20 @@ func EmitSessionCheckpoint(journalPath string, outRunpackPath string, opts Sessi } } checkpoint := schemarunpack.SessionCheckpoint{ - SchemaID: sessionCheckpointSchemaID, - SchemaVersion: sessionCheckpointSchemaV1, - CreatedAt: createdAt, - ProducerVersion: producerVersion, - SessionID: journal.SessionID, - RunID: journal.RunID, - CheckpointIndex: nextCheckpointIdx, - SequenceStart: sequenceStart, - SequenceEnd: sequenceEnd, - RunpackPath: runpackPath, - ManifestDigest: recordRes.Manifest.ManifestDigest, - PrevCheckpointDigest: prevCheckpointDigest, - CheckpointDigest: checkpointDigest, + SchemaID: sessionCheckpointSchemaID, + SchemaVersion: sessionCheckpointSchemaV1, + CreatedAt: createdAt, + ProducerVersion: producerVersion, + SessionID: journal.SessionID, + RunID: journal.RunID, + CheckpointIndex: nextCheckpointIdx, + SequenceStart: sequenceStart, + SequenceEnd: sequenceEnd, + RunpackPath: runpackPath, + ManifestDigest: recordRes.Manifest.ManifestDigest, + PrevCheckpointDigest: prevCheckpointDigest, + CheckpointDigest: checkpointDigest, + Relationship: buildSessionCheckpointRelationship(journal.SessionID, journal.RunID, checkpointRunID, checkpointDigest, recordRes.Manifest.ManifestDigest), SafetyInvariantVersion: safetyInvariantVersion, SafetyInvariantHash: safetyInvariantHash, } @@ -1147,6 +1182,390 @@ func uniqueSortedStrings(values []string) []string { return out } +var ( + runpackRelationshipParentKinds = map[string]struct{}{ + "trace": {}, + "run": {}, + "session": {}, + "intent": {}, + "policy": {}, + "agent": {}, + "evidence": {}, + } + runpackRelationshipEntityKinds = map[string]struct{}{ + "agent": {}, + "tool": {}, + "resource": {}, + "policy": {}, + "run": {}, + "trace": {}, + "delegation": {}, + "evidence": {}, + } + runpackRelationshipRoles = map[string]struct{}{ + "requester": {}, + "delegator": {}, + "delegate": {}, + } + runpackRelationshipEdgeKinds = map[string]struct{}{ + "delegates_to": {}, + "calls": {}, + "governed_by": {}, + "targets": {}, + "derived_from": {}, + "emits_evidence": {}, + } +) + +func buildSessionEventRelationship( + sessionID string, + runID string, + toolName string, + traceID string, + policyID string, + policyVersion string, + policyDigest string, + matchedRuleIDs []string, + actorIdentity string, + agentChain []schemacommon.AgentLink, +) *schemacommon.RelationshipEnvelope { + actorID := selectRunpackEventActor(actorIdentity, agentChain) + envelope := schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{ + Kind: "session", + ID: strings.TrimSpace(sessionID), + }, + ParentRecordID: strings.TrimSpace(sessionID), + EntityRefs: normalizeRunpackRelationshipRefs([]schemacommon.RelationshipRef{ + {Kind: "run", ID: strings.TrimSpace(runID)}, + {Kind: "tool", ID: strings.TrimSpace(toolName)}, + {Kind: "trace", ID: strings.TrimSpace(traceID)}, + {Kind: "policy", ID: strings.ToLower(strings.TrimSpace(policyDigest))}, + }), + PolicyRef: &schemacommon.PolicyRef{ + PolicyID: strings.TrimSpace(policyID), + PolicyVersion: strings.TrimSpace(policyVersion), + PolicyDigest: strings.ToLower(strings.TrimSpace(policyDigest)), + MatchedRuleIDs: uniqueSortedStrings(matchedRuleIDs), + }, + AgentChain: normalizeRunpackRelationshipAgentChain(agentChain), + } + if tool := strings.TrimSpace(toolName); tool != "" && strings.TrimSpace(policyDigest) != "" { + envelope.Edges = append(envelope.Edges, schemacommon.RelationshipEdge{ + Kind: "governed_by", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: tool}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: strings.ToLower(strings.TrimSpace(policyDigest))}, + }) + } + if actorID != "" && strings.TrimSpace(toolName) != "" { + envelope.Edges = append(envelope.Edges, schemacommon.RelationshipEdge{ + Kind: "calls", + From: schemacommon.RelationshipNodeRef{Kind: "agent", ID: actorID}, + To: schemacommon.RelationshipNodeRef{Kind: "tool", ID: strings.TrimSpace(toolName)}, + }) + } + envelope.Edges = normalizeRunpackRelationshipEdges(envelope.Edges) + envelope.RelatedEntityIDs = runpackRelationshipRefIDs(envelope.EntityRefs) + return normalizeRunpackRelationshipEnvelope(&envelope) +} + +func buildSessionCheckpointRelationship( + sessionID string, + runID string, + checkpointRunID string, + checkpointDigest string, + manifestDigest string, +) *schemacommon.RelationshipEnvelope { + envelope := schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{ + Kind: "session", + ID: strings.TrimSpace(sessionID), + }, + ParentRecordID: strings.TrimSpace(sessionID), + EntityRefs: normalizeRunpackRelationshipRefs([]schemacommon.RelationshipRef{ + {Kind: "run", ID: strings.TrimSpace(runID)}, + {Kind: "run", ID: strings.TrimSpace(checkpointRunID)}, + {Kind: "evidence", ID: strings.TrimSpace(checkpointDigest)}, + {Kind: "evidence", ID: strings.TrimSpace(manifestDigest)}, + }), + Edges: normalizeRunpackRelationshipEdges([]schemacommon.RelationshipEdge{ + { + Kind: "derived_from", + From: schemacommon.RelationshipNodeRef{Kind: "evidence", ID: strings.TrimSpace(checkpointDigest)}, + To: schemacommon.RelationshipNodeRef{Kind: "run", ID: strings.TrimSpace(checkpointRunID)}, + }, + }), + } + envelope.RelatedEntityIDs = runpackRelationshipRefIDs(envelope.EntityRefs) + return normalizeRunpackRelationshipEnvelope(&envelope) +} + +func buildRunTimelineRelationship(eventName, runID, evidenceID, traceID, toolName, policyDigest string) *schemacommon.RelationshipEnvelope { + envelope := schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{ + Kind: "run", + ID: strings.TrimSpace(runID), + }, + ParentRecordID: strings.TrimSpace(runID), + EntityRefs: normalizeRunpackRelationshipRefs([]schemacommon.RelationshipRef{ + {Kind: "trace", ID: strings.TrimSpace(traceID)}, + {Kind: "tool", ID: strings.TrimSpace(toolName)}, + {Kind: "policy", ID: strings.ToLower(strings.TrimSpace(policyDigest))}, + {Kind: "evidence", ID: strings.TrimSpace(evidenceID)}, + }), + } + switch strings.TrimSpace(eventName) { + case "session_event": + if strings.TrimSpace(traceID) != "" { + envelope.Edges = append(envelope.Edges, schemacommon.RelationshipEdge{ + Kind: "derived_from", + From: schemacommon.RelationshipNodeRef{Kind: "trace", ID: strings.TrimSpace(traceID)}, + To: schemacommon.RelationshipNodeRef{Kind: "run", ID: strings.TrimSpace(runID)}, + }) + } + if strings.TrimSpace(toolName) != "" && strings.TrimSpace(policyDigest) != "" { + envelope.Edges = append(envelope.Edges, schemacommon.RelationshipEdge{ + Kind: "governed_by", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: strings.TrimSpace(toolName)}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: strings.ToLower(strings.TrimSpace(policyDigest))}, + }) + } + case "session_checkpoint_emitted": + if strings.TrimSpace(evidenceID) != "" { + envelope.Edges = append(envelope.Edges, schemacommon.RelationshipEdge{ + Kind: "emits_evidence", + From: schemacommon.RelationshipNodeRef{Kind: "run", ID: strings.TrimSpace(runID)}, + To: schemacommon.RelationshipNodeRef{Kind: "evidence", ID: strings.TrimSpace(evidenceID)}, + }) + } + } + envelope.Edges = normalizeRunpackRelationshipEdges(envelope.Edges) + envelope.RelatedEntityIDs = runpackRelationshipRefIDs(envelope.EntityRefs) + return normalizeRunpackRelationshipEnvelope(&envelope) +} + +func normalizeRunpackRelationshipEnvelope(envelope *schemacommon.RelationshipEnvelope) *schemacommon.RelationshipEnvelope { + if envelope == nil { + return nil + } + normalized := *envelope + normalized.ParentRef = normalizeRunpackRelationshipParentRef(normalized.ParentRef) + normalized.ParentRecordID = strings.TrimSpace(normalized.ParentRecordID) + normalized.EntityRefs = normalizeRunpackRelationshipRefs(normalized.EntityRefs) + normalized.AgentChain = normalizeRunpackRelationshipAgentChain(normalized.AgentChain) + normalized.Edges = normalizeRunpackRelationshipEdges(normalized.Edges) + normalized.RelatedRecordIDs = uniqueSortedStrings(normalized.RelatedRecordIDs) + normalized.RelatedEntityIDs = uniqueSortedStrings(normalized.RelatedEntityIDs) + if normalized.PolicyRef != nil { + normalized.PolicyRef.PolicyID = strings.TrimSpace(normalized.PolicyRef.PolicyID) + normalized.PolicyRef.PolicyVersion = strings.TrimSpace(normalized.PolicyRef.PolicyVersion) + normalized.PolicyRef.PolicyDigest = strings.ToLower(strings.TrimSpace(normalized.PolicyRef.PolicyDigest)) + normalized.PolicyRef.MatchedRuleIDs = uniqueSortedStrings(normalized.PolicyRef.MatchedRuleIDs) + if normalized.PolicyRef.PolicyID == "" && + normalized.PolicyRef.PolicyVersion == "" && + normalized.PolicyRef.PolicyDigest == "" && + len(normalized.PolicyRef.MatchedRuleIDs) == 0 { + normalized.PolicyRef = nil + } + } + if len(normalized.RelatedEntityIDs) == 0 { + normalized.RelatedEntityIDs = runpackRelationshipRefIDs(normalized.EntityRefs) + } + if normalized.ParentRef == nil && + len(normalized.EntityRefs) == 0 && + normalized.PolicyRef == nil && + len(normalized.AgentChain) == 0 && + len(normalized.Edges) == 0 && + normalized.ParentRecordID == "" && + len(normalized.RelatedRecordIDs) == 0 && + len(normalized.RelatedEntityIDs) == 0 && + len(normalized.AgentLineage) == 0 { + return nil + } + return &normalized +} + +func normalizeRunpackRelationshipParentRef(ref *schemacommon.RelationshipNodeRef) *schemacommon.RelationshipNodeRef { + if ref == nil { + return nil + } + kind := strings.ToLower(strings.TrimSpace(ref.Kind)) + id := strings.TrimSpace(ref.ID) + if kind == "" || id == "" { + return nil + } + if _, ok := runpackRelationshipParentKinds[kind]; !ok { + return nil + } + return &schemacommon.RelationshipNodeRef{Kind: kind, ID: id} +} + +func normalizeRunpackRelationshipRefs(refs []schemacommon.RelationshipRef) []schemacommon.RelationshipRef { + if len(refs) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipRef, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + kind := strings.ToLower(strings.TrimSpace(ref.Kind)) + id := strings.TrimSpace(ref.ID) + if kind == "" || id == "" { + continue + } + if _, ok := runpackRelationshipEntityKinds[kind]; !ok { + continue + } + key := kind + "\x00" + id + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipRef{Kind: kind, ID: id}) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + return normalized[i].ID < normalized[j].ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeRunpackRelationshipAgentChain(chain []schemacommon.AgentLink) []schemacommon.AgentLink { + if len(chain) == 0 { + return nil + } + normalized := make([]schemacommon.AgentLink, 0, len(chain)) + seen := map[string]struct{}{} + for _, link := range chain { + role := strings.ToLower(strings.TrimSpace(link.Role)) + identity := strings.TrimSpace(link.Identity) + if role == "" || identity == "" { + continue + } + if _, ok := runpackRelationshipRoles[role]; !ok { + continue + } + key := role + "\x00" + identity + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.AgentLink{Role: role, Identity: identity}) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Role != normalized[j].Role { + return normalized[i].Role < normalized[j].Role + } + return normalized[i].Identity < normalized[j].Identity + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func selectRunpackEventActor(explicitActor string, agentChain []schemacommon.AgentLink) string { + if actor := strings.TrimSpace(explicitActor); actor != "" { + return actor + } + + bestIdentity := "" + bestPriority := 999 + for _, link := range agentChain { + role := strings.ToLower(strings.TrimSpace(link.Role)) + identity := strings.TrimSpace(link.Identity) + if identity == "" { + continue + } + var priority int + switch role { + case "delegate": + priority = 0 + case "requester": + priority = 1 + case "delegator": + priority = 2 + default: + continue + } + if priority < bestPriority { + bestPriority = priority + bestIdentity = identity + if priority == 0 { + return bestIdentity + } + } + } + return bestIdentity +} + +func normalizeRunpackRelationshipEdges(edges []schemacommon.RelationshipEdge) []schemacommon.RelationshipEdge { + if len(edges) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipEdge, 0, len(edges)) + seen := map[string]struct{}{} + for _, edge := range edges { + kind := strings.ToLower(strings.TrimSpace(edge.Kind)) + fromKind := strings.ToLower(strings.TrimSpace(edge.From.Kind)) + fromID := strings.TrimSpace(edge.From.ID) + toKind := strings.ToLower(strings.TrimSpace(edge.To.Kind)) + toID := strings.TrimSpace(edge.To.ID) + if kind == "" || fromKind == "" || fromID == "" || toKind == "" || toID == "" { + continue + } + if _, ok := runpackRelationshipEdgeKinds[kind]; !ok { + continue + } + key := kind + "\x00" + fromKind + "\x00" + fromID + "\x00" + toKind + "\x00" + toID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipEdge{ + Kind: kind, + From: schemacommon.RelationshipNodeRef{Kind: fromKind, ID: fromID}, + To: schemacommon.RelationshipNodeRef{Kind: toKind, ID: toID}, + }) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + if normalized[i].From.Kind != normalized[j].From.Kind { + return normalized[i].From.Kind < normalized[j].From.Kind + } + if normalized[i].From.ID != normalized[j].From.ID { + return normalized[i].From.ID < normalized[j].From.ID + } + if normalized[i].To.Kind != normalized[j].To.Kind { + return normalized[i].To.Kind < normalized[j].To.Kind + } + return normalized[i].To.ID < normalized[j].To.ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func runpackRelationshipRefIDs(refs []schemacommon.RelationshipRef) []string { + if len(refs) == 0 { + return nil + } + ids := make([]string, 0, len(refs)) + for _, ref := range refs { + if id := strings.TrimSpace(ref.ID); id != "" { + ids = append(ids, id) + } + } + return uniqueSortedStrings(ids) +} + func statLocalOrAbsolutePath(path string) (os.FileInfo, error) { normalizedPath, err := normalizeOutputPath(path) if err != nil { diff --git a/core/runpack/session_test.go b/core/runpack/session_test.go index 0fe255b..bfa799c 100644 --- a/core/runpack/session_test.go +++ b/core/runpack/session_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" ) @@ -50,20 +51,41 @@ func TestSessionJournalLifecycleAndCheckpointChain(t *testing.T) { t.Fatalf("append session event 1: %v", err) } if _, err := AppendSessionEvent(journalPath, SessionAppendOptions{ - CreatedAt: startedAt.Add(2 * time.Second), - IntentID: "intent_2", - ToolName: "tool.write", - IntentDigest: strings.Repeat("c", 64), - PolicyDigest: strings.Repeat("d", 64), - TraceID: "trace_2", - TracePath: filepath.Join(workDir, "traces", "trace_2.json"), - Verdict: "block", - ReasonCodes: []string{"policy_block"}, - Violations: []string{"unauthorized_target"}, + CreatedAt: startedAt.Add(2 * time.Second), + IntentID: "intent_2", + ToolName: "tool.write", + IntentDigest: strings.Repeat("c", 64), + PolicyDigest: strings.Repeat("d", 64), + PolicyID: "gait.gate.policy", + PolicyVersion: "1.0.0", + MatchedRuleIDs: []string{"allow-write", "allow-write"}, + TraceID: "trace_2", + TracePath: filepath.Join(workDir, "traces", "trace_2.json"), + Verdict: "block", + ReasonCodes: []string{"policy_block"}, + Violations: []string{"unauthorized_target"}, }); err != nil { t.Fatalf("append session event 2: %v", err) } + journalAfterAppend, err := ReadSessionJournal(journalPath) + if err != nil { + t.Fatalf("read session journal after append: %v", err) + } + if len(journalAfterAppend.Events) != 2 { + t.Fatalf("expected two session events after append, got %d", len(journalAfterAppend.Events)) + } + secondEvent := journalAfterAppend.Events[1] + if secondEvent.PolicyID != "gait.gate.policy" || secondEvent.PolicyVersion != "1.0.0" { + t.Fatalf("expected policy lineage on session event, got id=%q version=%q", secondEvent.PolicyID, secondEvent.PolicyVersion) + } + if len(secondEvent.MatchedRuleIDs) != 1 || secondEvent.MatchedRuleIDs[0] != "allow-write" { + t.Fatalf("expected normalized matched_rule_ids on session event, got %#v", secondEvent.MatchedRuleIDs) + } + if secondEvent.Relationship == nil || secondEvent.Relationship.ParentRef == nil || secondEvent.Relationship.ParentRef.Kind != "session" { + t.Fatalf("expected relationship envelope with session parent on session event: %#v", secondEvent.Relationship) + } + status, err := GetSessionStatus(journalPath) if err != nil { t.Fatalf("get session status: %v", err) @@ -83,6 +105,9 @@ func TestSessionJournalLifecycleAndCheckpointChain(t *testing.T) { if checkpoint1.Checkpoint.CheckpointIndex != 1 { t.Fatalf("unexpected first checkpoint index: %d", checkpoint1.Checkpoint.CheckpointIndex) } + if checkpoint1.Checkpoint.Relationship == nil || checkpoint1.Checkpoint.Relationship.ParentRef == nil || checkpoint1.Checkpoint.Relationship.ParentRef.Kind != "session" { + t.Fatalf("expected relationship envelope on checkpoint: %#v", checkpoint1.Checkpoint.Relationship) + } if !ContainsSessionChainPath(chainPath) { t.Fatalf("expected json session chain path, got %s", chainPath) } @@ -1003,3 +1028,129 @@ func TestWithSessionLockTimeoutIncludesDiagnosticsAndEnvOverrides(t *testing.T) t.Fatalf("expected lock wait close to configured timeout") } } + +func TestSessionRelationshipBuildersNormalizeAndSort(t *testing.T) { + eventRelationship := buildSessionEventRelationship( + "sess_demo", + "run_demo", + "tool.write", + "trace_demo", + "gait.gate.policy", + "1.0.0", + strings.Repeat("b", 64), + []string{"rule_b", "rule_a", "rule_a"}, + "agent.exec", + []schemacommon.AgentLink{ + {Identity: "agent.b", Role: "requester"}, + {Identity: "agent.a", Role: "requester"}, + {Identity: "agent.ignore", Role: "approver"}, + }, + ) + if eventRelationship == nil { + t.Fatalf("expected session event relationship") + } + if eventRelationship.ParentRef == nil || eventRelationship.ParentRef.Kind != "session" || eventRelationship.ParentRef.ID != "sess_demo" { + t.Fatalf("unexpected session parent_ref: %#v", eventRelationship.ParentRef) + } + if eventRelationship.PolicyRef == nil || eventRelationship.PolicyRef.PolicyID != "gait.gate.policy" || eventRelationship.PolicyRef.PolicyVersion != "1.0.0" { + t.Fatalf("expected policy lineage in relationship: %#v", eventRelationship.PolicyRef) + } + if got := eventRelationship.PolicyRef.MatchedRuleIDs; len(got) != 2 || got[0] != "rule_a" || got[1] != "rule_b" { + t.Fatalf("expected matched rules to be deduplicated/sorted, got %#v", got) + } + if len(eventRelationship.AgentChain) != 2 || eventRelationship.AgentChain[0].Identity != "agent.a" { + t.Fatalf("expected normalized agent chain ordering, got %#v", eventRelationship.AgentChain) + } + if len(eventRelationship.Edges) == 0 { + t.Fatalf("expected relationship edges for session event") + } + foundCallsEdge := false + for _, edge := range eventRelationship.Edges { + if edge.Kind == "calls" && edge.From.Kind == "agent" && edge.From.ID == "agent.exec" { + foundCallsEdge = true + break + } + } + if !foundCallsEdge { + t.Fatalf("expected calls edge to use explicit actor identity, got %#v", eventRelationship.Edges) + } + + checkpointRelationship := buildSessionCheckpointRelationship( + "sess_demo", + "run_demo", + "run_demo_cp_0001", + strings.Repeat("c", 64), + strings.Repeat("d", 64), + ) + if checkpointRelationship == nil { + t.Fatalf("expected checkpoint relationship") + } + if checkpointRelationship.ParentRef == nil || checkpointRelationship.ParentRef.Kind != "session" { + t.Fatalf("unexpected checkpoint parent_ref: %#v", checkpointRelationship.ParentRef) + } + if len(checkpointRelationship.EntityRefs) < 2 { + t.Fatalf("expected checkpoint entity refs, got %#v", checkpointRelationship.EntityRefs) + } + + timelineRelationship := buildRunTimelineRelationship( + "session_event", + "run_demo_cp_0001", + "sess_demo", + "trace_demo", + "tool.write", + strings.Repeat("e", 64), + ) + if timelineRelationship == nil { + t.Fatalf("expected timeline relationship") + } + if timelineRelationship.ParentRef == nil || timelineRelationship.ParentRef.Kind != "run" { + t.Fatalf("unexpected timeline parent_ref: %#v", timelineRelationship.ParentRef) + } + if len(timelineRelationship.Edges) == 0 { + t.Fatalf("expected timeline relationship edges") + } + + checkpointTimelineRelationship := buildRunTimelineRelationship( + "session_checkpoint_emitted", + "run_demo_cp_0001", + "checkpoint:1", + "", + "", + "", + ) + if checkpointTimelineRelationship == nil { + t.Fatalf("expected checkpoint timeline relationship") + } + foundEvidenceEmission := false + for _, edge := range checkpointTimelineRelationship.Edges { + if edge.Kind == "emits_evidence" && edge.To.Kind == "evidence" && edge.To.ID == "checkpoint:1" { + foundEvidenceEmission = true + break + } + } + if !foundEvidenceEmission { + t.Fatalf("expected checkpoint emits_evidence edge to target checkpoint ref, got %#v", checkpointTimelineRelationship.Edges) + } +} + +func TestNormalizeRunpackRelationshipEnvelopeDropsInvalidEntries(t *testing.T) { + invalid := &schemacommon.RelationshipEnvelope{ + ParentRef: &schemacommon.RelationshipNodeRef{Kind: "invalid", ID: "root"}, + EntityRefs: []schemacommon.RelationshipRef{ + {Kind: "invalid", ID: "x"}, + }, + AgentChain: []schemacommon.AgentLink{ + {Identity: "agent.demo", Role: "approver"}, + }, + Edges: []schemacommon.RelationshipEdge{ + { + Kind: "invalid", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: "tool.write"}, + To: schemacommon.RelationshipNodeRef{Kind: "policy", ID: strings.Repeat("f", 64)}, + }, + }, + } + if normalized := normalizeRunpackRelationshipEnvelope(invalid); normalized != nil { + t.Fatalf("expected invalid relationship envelope to collapse to nil, got %#v", normalized) + } +} diff --git a/core/schema/relationship_fixture_test.go b/core/schema/relationship_fixture_test.go new file mode 100644 index 0000000..73c373c --- /dev/null +++ b/core/schema/relationship_fixture_test.go @@ -0,0 +1,300 @@ +package schema_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + schemagate "github.com/Clyra-AI/gait/core/schema/v1/gate" + schemarunpack "github.com/Clyra-AI/gait/core/schema/v1/runpack" + validate "github.com/Clyra-AI/proof/schema" +) + +func TestRelationshipFixturesValidateAgainstSchemas(t *testing.T) { + repoRoot := resolveRepoRoot(t) + testCases := []struct { + name string + schema string + fixture string + shouldErr bool + }{ + { + name: "intent_without_relationship", + schema: "schemas/v1/gate/intent_request.schema.json", + fixture: "core/schema/testdata/gate_intent_request_valid.json", + }, + { + name: "intent_with_relationship", + schema: "schemas/v1/gate/intent_request.schema.json", + fixture: "core/schema/testdata/gate_intent_request_relationship_valid.json", + }, + { + name: "trace_without_relationship", + schema: "schemas/v1/gate/trace_record.schema.json", + fixture: "core/schema/testdata/gate_trace_record_valid.json", + }, + { + name: "trace_with_relationship", + schema: "schemas/v1/gate/trace_record.schema.json", + fixture: "core/schema/testdata/gate_trace_record_relationship_valid.json", + }, + { + name: "trace_invalid_relationship", + schema: "schemas/v1/gate/trace_record.schema.json", + fixture: "core/schema/testdata/gate_trace_record_relationship_invalid.json", + shouldErr: true, + }, + { + name: "approval_audit_without_relationship", + schema: "schemas/v1/gate/approval_audit_record.schema.json", + fixture: "core/schema/testdata/gate_approval_audit_record_valid.json", + }, + { + name: "approval_audit_with_relationship", + schema: "schemas/v1/gate/approval_audit_record.schema.json", + fixture: "core/schema/testdata/gate_approval_audit_record_relationship_valid.json", + }, + { + name: "delegation_audit_without_relationship", + schema: "schemas/v1/gate/delegation_audit_record.schema.json", + fixture: "core/schema/testdata/gate_delegation_audit_record_valid.json", + }, + { + name: "delegation_audit_with_relationship", + schema: "schemas/v1/gate/delegation_audit_record.schema.json", + fixture: "core/schema/testdata/gate_delegation_audit_record_relationship_valid.json", + }, + { + name: "inventory_without_relationship", + schema: "schemas/v1/scout/inventory_snapshot.schema.json", + fixture: "core/schema/testdata/scout_inventory_snapshot_valid.json", + }, + { + name: "inventory_with_relationship", + schema: "schemas/v1/scout/inventory_snapshot.schema.json", + fixture: "core/schema/testdata/scout_inventory_snapshot_relationship_valid.json", + }, + { + name: "run_without_relationship", + schema: "schemas/v1/runpack/run.schema.json", + fixture: "core/schema/testdata/run_valid.json", + }, + { + name: "run_with_relationship", + schema: "schemas/v1/runpack/run.schema.json", + fixture: "core/schema/testdata/run_relationship_valid.json", + }, + { + name: "session_journal_without_relationship", + schema: "schemas/v1/runpack/session_journal.schema.json", + fixture: "core/schema/testdata/session_journal_valid.json", + }, + { + name: "session_journal_with_relationship", + schema: "schemas/v1/runpack/session_journal.schema.json", + fixture: "core/schema/testdata/session_journal_relationship_valid.json", + }, + { + name: "session_checkpoint_without_relationship", + schema: "schemas/v1/runpack/session_checkpoint.schema.json", + fixture: "core/schema/testdata/session_checkpoint_valid.json", + }, + { + name: "session_checkpoint_with_relationship", + schema: "schemas/v1/runpack/session_checkpoint.schema.json", + fixture: "core/schema/testdata/session_checkpoint_relationship_valid.json", + }, + { + name: "session_chain_without_relationship", + schema: "schemas/v1/runpack/session_chain.schema.json", + fixture: "core/schema/testdata/session_chain_valid.json", + }, + { + name: "session_chain_with_relationship", + schema: "schemas/v1/runpack/session_chain.schema.json", + fixture: "core/schema/testdata/session_chain_relationship_valid.json", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + schemaPath := filepath.Join(repoRoot, testCase.schema) + fixturePath := filepath.Join(repoRoot, testCase.fixture) + // #nosec G304 -- paths are repository-local constants in test table. + data, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + err = validate.ValidateJSON(schemaPath, data) + if testCase.shouldErr { + if err == nil { + t.Fatalf("expected schema validation error") + } + return + } + if err != nil { + t.Fatalf("validate fixture: %v", err) + } + }) + } +} + +func TestTraceRelationshipFixturePreservesDeterministicFields(t *testing.T) { + repoRoot := resolveRepoRoot(t) + baseFixture := filepath.Join(repoRoot, "core/schema/testdata/gate_trace_record_valid.json") + relationshipFixture := filepath.Join(repoRoot, "core/schema/testdata/gate_trace_record_relationship_valid.json") + base := map[string]any{} + withRelationship := map[string]any{} + + // #nosec G304 -- paths are fixed repository-local fixtures. + baseRaw, err := os.ReadFile(baseFixture) + if err != nil { + t.Fatalf("read base fixture: %v", err) + } + // #nosec G304 -- paths are fixed repository-local fixtures. + relationshipRaw, err := os.ReadFile(relationshipFixture) + if err != nil { + t.Fatalf("read relationship fixture: %v", err) + } + if err := json.Unmarshal(baseRaw, &base); err != nil { + t.Fatalf("parse base fixture: %v", err) + } + if err := json.Unmarshal(relationshipRaw, &withRelationship); err != nil { + t.Fatalf("parse relationship fixture: %v", err) + } + + keys := []string{"trace_id", "intent_digest", "policy_digest", "verdict"} + for _, key := range keys { + if base[key] != withRelationship[key] { + t.Fatalf("expected %s to remain stable, base=%v relationship=%v", key, base[key], withRelationship[key]) + } + } +} + +func TestBaseFixturesHaveStableByteDigests(t *testing.T) { + repoRoot := resolveRepoRoot(t) + testCases := []struct { + fixture string + expectedSHA256 string + }{ + { + fixture: "core/schema/testdata/gate_trace_record_valid.json", + expectedSHA256: "dbf1ff6fe53e2df77c3a6846a3e76e63c97c8fee1f5706ea3fb908e34c423a3b", + }, + { + fixture: "core/schema/testdata/gate_intent_request_valid.json", + expectedSHA256: "7fc28ca4e27eb77b5a3f5e0d21ea8e97ec626388355f7139612ad2723585f7b9", + }, + { + fixture: "core/schema/testdata/run_valid.json", + expectedSHA256: "de76b8f9595d3a7df632868d0e2743202d1d102dab9eb43da5855d4196150b6d", + }, + { + fixture: "core/schema/testdata/session_journal_valid.json", + expectedSHA256: "002b158b8b8cc822555675998ae8eb97890a95b4f372dcd466c81a71c3401b43", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.fixture, func(t *testing.T) { + fixturePath := filepath.Join(repoRoot, testCase.fixture) + // #nosec G304 -- fixture path is repository-local in test table. + content, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + digest := sha256.Sum256(normalizeFixtureLineEndings(content)) + if got := hex.EncodeToString(digest[:]); got != testCase.expectedSHA256 { + t.Fatalf("fixture digest changed: got %s want %s", got, testCase.expectedSHA256) + } + }) + } +} + +func TestReadersTolerateUnknownAdditiveFields(t *testing.T) { + repoRoot := resolveRepoRoot(t) + testCases := []struct { + name string + fixture string + decode func([]byte) error + }{ + { + name: "trace_reader", + fixture: "core/schema/testdata/gate_trace_record_relationship_valid.json", + decode: func(data []byte) error { + var record schemagate.TraceRecord + return json.Unmarshal(data, &record) + }, + }, + { + name: "intent_reader", + fixture: "core/schema/testdata/gate_intent_request_relationship_valid.json", + decode: func(data []byte) error { + var record schemagate.IntentRequest + return json.Unmarshal(data, &record) + }, + }, + { + name: "run_reader", + fixture: "core/schema/testdata/run_relationship_valid.json", + decode: func(data []byte) error { + var record schemarunpack.Run + return json.Unmarshal(data, &record) + }, + }, + { + name: "session_journal_reader", + fixture: "core/schema/testdata/session_journal_relationship_valid.json", + decode: func(data []byte) error { + var record schemarunpack.SessionJournal + return json.Unmarshal(data, &record) + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + fixturePath := filepath.Join(repoRoot, testCase.fixture) + // #nosec G304 -- fixture path is repository-local in test table. + raw, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("parse fixture: %v", err) + } + payload["unknown_top_level"] = "additive" + if relationship, ok := payload["relationship"].(map[string]any); ok { + relationship["unknown_relationship_field"] = true + payload["relationship"] = relationship + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + if err := testCase.decode(encoded); err != nil { + t.Fatalf("decode with additive fields: %v", err) + } + }) + } +} + +func resolveRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatalf("resolve caller path") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func normalizeFixtureLineEndings(content []byte) []byte { + // Keep digest checks stable across Git checkout EOL conversion on Windows. + return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) +} diff --git a/core/schema/testdata/gate_approval_audit_record_relationship_valid.json b/core/schema/testdata/gate_approval_audit_record_relationship_valid.json new file mode 100644 index 0000000..32c799d --- /dev/null +++ b/core/schema/testdata/gate_approval_audit_record_relationship_valid.json @@ -0,0 +1,53 @@ +{ + "schema_id": "gait.gate.approval_audit_record", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "trace_id": "trace_abc123", + "tool_name": "tool.write", + "intent_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "required_approvals": 1, + "valid_approvals": 1, + "approved": true, + "approvers": ["alice"], + "relationship": { + "parent_ref": {"kind": "trace", "id": "trace_abc123"}, + "entity_refs": [ + {"kind": "agent", "id": "alice"}, + {"kind": "policy", "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + {"kind": "tool", "id": "tool.write"}, + {"kind": "trace", "id": "trace_abc123"} + ], + "policy_ref": { + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "policy_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "matched_rule_ids": ["allow-writer"] + }, + "edges": [ + { + "kind": "governed_by", + "from": {"kind": "tool", "id": "tool.write"}, + "to": {"kind": "policy", "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + } + ], + "parent_record_id": "trace_abc123", + "related_entity_ids": [ + "alice", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "tool.write", + "trace_abc123" + ] + }, + "entries": [ + { + "token_id": "token_a", + "approver_identity": "alice", + "reason_code": "ticket-123", + "scope": ["tool:tool.write"], + "expires_at": "2026-02-05T01:00:00Z", + "valid": true + } + ] +} diff --git a/core/schema/testdata/gate_delegation_audit_record_relationship_valid.json b/core/schema/testdata/gate_delegation_audit_record_relationship_valid.json new file mode 100644 index 0000000..32478de --- /dev/null +++ b/core/schema/testdata/gate_delegation_audit_record_relationship_valid.json @@ -0,0 +1,46 @@ +{ + "schema_id": "gait.gate.delegation_audit_record", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "trace_id": "trace_demo", + "tool_name": "tool.write", + "intent_digest": "1111111111111111111111111111111111111111111111111111111111111111", + "policy_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "delegation_required": true, + "valid_delegations": 1, + "delegated": true, + "delegation_ref": "delegation_demo", + "relationship": { + "parent_ref": {"kind": "trace", "id": "trace_demo"}, + "entity_refs": [ + {"kind": "agent", "id": "agent.lead"}, + {"kind": "agent", "id": "agent.specialist"}, + {"kind": "policy", "id": "2222222222222222222222222222222222222222222222222222222222222222"}, + {"kind": "tool", "id": "tool.write"}, + {"kind": "trace", "id": "trace_demo"} + ], + "policy_ref": {"policy_digest": "2222222222222222222222222222222222222222222222222222222222222222"}, + "agent_chain": [ + {"identity": "agent.lead", "role": "delegator"}, + {"identity": "agent.specialist", "role": "delegate"} + ], + "edges": [ + { + "kind": "delegates_to", + "from": {"kind": "agent", "id": "agent.lead"}, + "to": {"kind": "agent", "id": "agent.specialist"} + } + ] + }, + "entries": [ + { + "token_id": "delegation_demo", + "delegator_identity": "agent.lead", + "delegate_identity": "agent.specialist", + "scope": ["tool:tool.write"], + "expires_at": "2026-02-05T01:00:00Z", + "valid": true + } + ] +} diff --git a/core/schema/testdata/gate_intent_request_relationship_valid.json b/core/schema/testdata/gate_intent_request_relationship_valid.json new file mode 100644 index 0000000..076484f --- /dev/null +++ b/core/schema/testdata/gate_intent_request_relationship_valid.json @@ -0,0 +1,42 @@ +{ + "schema_id": "gait.gate.intent_request", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "tool_name": "tool.demo", + "args": { "foo": "bar" }, + "args_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "targets": [ + { "kind": "path", "value": "/tmp/out.txt" } + ], + "arg_provenance": [ + { "arg_path": "args.foo", "source": "user" } + ], + "relationship": { + "parent_ref": { "kind": "session", "id": "sess_demo" }, + "entity_refs": [ + { "kind": "agent", "id": "agent.demo" }, + { "kind": "tool", "id": "tool.demo" } + ], + "policy_ref": { + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "policy_digest": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "agent_chain": [ + { "identity": "agent.demo", "role": "requester" } + ], + "edges": [ + { + "kind": "calls", + "from": { "kind": "agent", "id": "agent.demo" }, + "to": { "kind": "tool", "id": "tool.demo" } + } + ] + }, + "context": { + "identity": "user", + "workspace": "/tmp/work", + "risk_class": "low" + } +} diff --git a/core/schema/testdata/gate_trace_record_relationship_invalid.json b/core/schema/testdata/gate_trace_record_relationship_invalid.json new file mode 100644 index 0000000..bc560f2 --- /dev/null +++ b/core/schema/testdata/gate_trace_record_relationship_invalid.json @@ -0,0 +1,17 @@ +{ + "schema_id": "gait.gate.trace", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "trace_id": "trace_1", + "tool_name": "tool.demo", + "args_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "intent_digest": "1111111111111111111111111111111111111111111111111111111111111111", + "policy_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "verdict": "allow", + "relationship": { + "entity_refs": [ + {"kind": "not_allowed", "id": "agent.demo"} + ] + } +} diff --git a/core/schema/testdata/gate_trace_record_relationship_valid.json b/core/schema/testdata/gate_trace_record_relationship_valid.json new file mode 100644 index 0000000..6c3b515 --- /dev/null +++ b/core/schema/testdata/gate_trace_record_relationship_valid.json @@ -0,0 +1,44 @@ +{ + "schema_id": "gait.gate.trace", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "trace_id": "trace_1", + "tool_name": "tool.demo", + "args_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "intent_digest": "1111111111111111111111111111111111111111111111111111111111111111", + "policy_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "matched_rule_ids": ["allow-demo"], + "verdict": "allow", + "relationship": { + "parent_ref": {"kind": "session", "id": "sess_demo"}, + "entity_refs": [ + {"kind": "agent", "id": "agent.demo"}, + {"kind": "tool", "id": "tool.demo"}, + {"kind": "trace", "id": "trace_1"} + ], + "policy_ref": { + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "policy_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "matched_rule_ids": ["allow-demo"] + }, + "agent_chain": [ + {"identity": "agent.demo", "role": "requester"} + ], + "edges": [ + { + "kind": "calls", + "from": {"kind": "agent", "id": "agent.demo"}, + "to": {"kind": "tool", "id": "tool.demo"} + }, + { + "kind": "governed_by", + "from": {"kind": "tool", "id": "tool.demo"}, + "to": {"kind": "policy", "id": "3333333333333333333333333333333333333333333333333333333333333333"} + } + ] + } +} diff --git a/core/schema/testdata/run_relationship_valid.json b/core/schema/testdata/run_relationship_valid.json new file mode 100644 index 0000000..3933331 --- /dev/null +++ b/core/schema/testdata/run_relationship_valid.json @@ -0,0 +1,29 @@ +{ + "schema_id": "gait.runpack.run", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "run_id": "run_demo", + "env": { "os": "linux", "arch": "amd64", "runtime": "go" }, + "timeline": [ + { + "event": "session_event", + "ts": "2026-02-05T00:00:00Z", + "ref": "trace_demo", + "relationship": { + "parent_ref": { "kind": "run", "id": "run_demo" }, + "entity_refs": [ + { "kind": "trace", "id": "trace_demo" }, + { "kind": "tool", "id": "tool.write" } + ], + "edges": [ + { + "kind": "derived_from", + "from": { "kind": "trace", "id": "trace_demo" }, + "to": { "kind": "run", "id": "run_demo" } + } + ] + } + } + ] +} diff --git a/core/schema/testdata/scout_inventory_snapshot_relationship_valid.json b/core/schema/testdata/scout_inventory_snapshot_relationship_valid.json new file mode 100644 index 0000000..95ff49e --- /dev/null +++ b/core/schema/testdata/scout_inventory_snapshot_relationship_valid.json @@ -0,0 +1,33 @@ +{ + "schema_id": "gait.scout.inventory_snapshot", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "snapshot_id": "snapshot_001", + "workspace": "/tmp/workspace", + "items": [ + { + "id": "tool_1", + "kind": "tool", + "name": "tool.write", + "locator": "adapter://tool.write", + "risk_level": "high", + "tags": ["prod", "write"], + "last_seen_run": "run_demo", + "relationship": { + "parent_ref": {"kind": "evidence", "id": "snapshot_001"}, + "entity_refs": [ + {"kind": "resource", "id": "adapter://tool.write"}, + {"kind": "tool", "id": "tool_1"} + ], + "edges": [ + { + "kind": "derived_from", + "from": {"kind": "tool", "id": "tool_1"}, + "to": {"kind": "resource", "id": "adapter://tool.write"} + } + ] + } + } + ] +} diff --git a/core/schema/testdata/session_chain_relationship_valid.json b/core/schema/testdata/session_chain_relationship_valid.json new file mode 100644 index 0000000..45e9a61 --- /dev/null +++ b/core/schema/testdata/session_chain_relationship_valid.json @@ -0,0 +1,31 @@ +{ + "schema_id": "gait.runpack.session_chain", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:10:00Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "checkpoints": [ + { + "schema_id": "gait.runpack.session_checkpoint", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:10:00Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "checkpoint_index": 1, + "sequence_start": 1, + "sequence_end": 5, + "runpack_path": "gait-out/runpack_run_demo_cp_0001.zip", + "manifest_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "checkpoint_digest": "4444444444444444444444444444444444444444444444444444444444444444", + "relationship": { + "parent_ref": { "kind": "session", "id": "sess_demo" }, + "entity_refs": [ + { "kind": "run", "id": "run_demo" }, + { "kind": "evidence", "id": "4444444444444444444444444444444444444444444444444444444444444444" } + ] + } + } + ] +} diff --git a/core/schema/testdata/session_checkpoint_relationship_valid.json b/core/schema/testdata/session_checkpoint_relationship_valid.json new file mode 100644 index 0000000..ac1f75b --- /dev/null +++ b/core/schema/testdata/session_checkpoint_relationship_valid.json @@ -0,0 +1,28 @@ +{ + "schema_id": "gait.runpack.session_checkpoint", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:10:00Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "checkpoint_index": 1, + "sequence_start": 1, + "sequence_end": 5, + "runpack_path": "gait-out/runpack_run_demo_cp_0001.zip", + "manifest_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "checkpoint_digest": "4444444444444444444444444444444444444444444444444444444444444444", + "relationship": { + "parent_ref": { "kind": "session", "id": "sess_demo" }, + "entity_refs": [ + { "kind": "run", "id": "run_demo" }, + { "kind": "evidence", "id": "4444444444444444444444444444444444444444444444444444444444444444" } + ], + "edges": [ + { + "kind": "derived_from", + "from": { "kind": "evidence", "id": "4444444444444444444444444444444444444444444444444444444444444444" }, + "to": { "kind": "run", "id": "run_demo" } + } + ] + } +} diff --git a/core/schema/testdata/session_journal_relationship_valid.json b/core/schema/testdata/session_journal_relationship_valid.json new file mode 100644 index 0000000..dd146e0 --- /dev/null +++ b/core/schema/testdata/session_journal_relationship_valid.json @@ -0,0 +1,81 @@ +{ + "schema_id": "gait.runpack.session_journal", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:00Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "started_at": "2026-02-05T00:00:00Z", + "events": [ + { + "schema_id": "gait.runpack.session_event", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:00:01Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "sequence": 1, + "tool_name": "tool.write", + "intent_digest": "1111111111111111111111111111111111111111111111111111111111111111", + "policy_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "matched_rule_ids": ["allow-writer"], + "verdict": "allow", + "relationship": { + "parent_ref": { "kind": "session", "id": "sess_demo" }, + "entity_refs": [ + { "kind": "run", "id": "run_demo" }, + { "kind": "tool", "id": "tool.write" }, + { "kind": "policy", "id": "2222222222222222222222222222222222222222222222222222222222222222" } + ], + "policy_ref": { + "policy_id": "gait.gate.policy", + "policy_version": "1.0.0", + "policy_digest": "2222222222222222222222222222222222222222222222222222222222222222", + "matched_rule_ids": ["allow-writer"] + }, + "agent_chain": [ + { "identity": "agent.demo", "role": "requester" } + ], + "edges": [ + { + "kind": "governed_by", + "from": { "kind": "tool", "id": "tool.write" }, + "to": { "kind": "policy", "id": "2222222222222222222222222222222222222222222222222222222222222222" } + } + ] + } + } + ], + "checkpoints": [ + { + "schema_id": "gait.runpack.session_checkpoint", + "schema_version": "1.0.0", + "created_at": "2026-02-05T00:10:00Z", + "producer_version": "0.0.0-dev", + "session_id": "sess_demo", + "run_id": "run_demo", + "checkpoint_index": 1, + "sequence_start": 1, + "sequence_end": 1, + "runpack_path": "gait-out/runpack_run_demo_cp_0001.zip", + "manifest_digest": "3333333333333333333333333333333333333333333333333333333333333333", + "checkpoint_digest": "4444444444444444444444444444444444444444444444444444444444444444", + "relationship": { + "parent_ref": { "kind": "session", "id": "sess_demo" }, + "entity_refs": [ + { "kind": "run", "id": "run_demo" }, + { "kind": "evidence", "id": "4444444444444444444444444444444444444444444444444444444444444444" } + ], + "edges": [ + { + "kind": "derived_from", + "from": { "kind": "evidence", "id": "4444444444444444444444444444444444444444444444444444444444444444" }, + "to": { "kind": "run", "id": "run_demo_cp_0001" } + } + ] + } + } + ] +} diff --git a/core/schema/v1/common/types.go b/core/schema/v1/common/types.go new file mode 100644 index 0000000..9ad4814 --- /dev/null +++ b/core/schema/v1/common/types.go @@ -0,0 +1,47 @@ +package common + +type RelationshipEnvelope struct { + ParentRef *RelationshipNodeRef `json:"parent_ref,omitempty"` + EntityRefs []RelationshipRef `json:"entity_refs,omitempty"` + PolicyRef *PolicyRef `json:"policy_ref,omitempty"` + AgentChain []AgentLink `json:"agent_chain,omitempty"` + Edges []RelationshipEdge `json:"edges,omitempty"` + ParentRecordID string `json:"parent_record_id,omitempty"` + RelatedRecordIDs []string `json:"related_record_ids,omitempty"` + RelatedEntityIDs []string `json:"related_entity_ids,omitempty"` + AgentLineage []AgentLineage `json:"agent_lineage,omitempty"` +} + +type RelationshipNodeRef struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +type RelationshipRef struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +type PolicyRef struct { + PolicyID string `json:"policy_id,omitempty"` + PolicyVersion string `json:"policy_version,omitempty"` + PolicyDigest string `json:"policy_digest,omitempty"` + MatchedRuleIDs []string `json:"matched_rule_ids,omitempty"` +} + +type AgentLink struct { + Identity string `json:"identity"` + Role string `json:"role"` +} + +type RelationshipEdge struct { + Kind string `json:"kind"` + From RelationshipNodeRef `json:"from"` + To RelationshipNodeRef `json:"to"` +} + +type AgentLineage struct { + AgentID string `json:"agent_id"` + DelegatedBy string `json:"delegated_by,omitempty"` + DelegationRecordID string `json:"delegation_record_id,omitempty"` +} diff --git a/core/schema/v1/gate/types.go b/core/schema/v1/gate/types.go index f830f9d..eeb5716 100644 --- a/core/schema/v1/gate/types.go +++ b/core/schema/v1/gate/types.go @@ -1,39 +1,47 @@ package gate -import "time" +import ( + "time" + + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" +) type TraceRecord struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ObservedAt time.Time `json:"observed_at,omitempty"` - ProducerVersion string `json:"producer_version"` - TraceID string `json:"trace_id"` - EventID string `json:"event_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - ToolName string `json:"tool_name"` - ArgsDigest string `json:"args_digest"` - IntentDigest string `json:"intent_digest"` - PolicyDigest string `json:"policy_digest"` - Verdict string `json:"verdict"` - ContextSetDigest string `json:"context_set_digest,omitempty"` - ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` - ContextRefCount int `json:"context_ref_count,omitempty"` - ContextSource string `json:"context_source,omitempty"` - Script bool `json:"script,omitempty"` - StepCount int `json:"step_count,omitempty"` - ScriptHash string `json:"script_hash,omitempty"` - CompositeRiskClass string `json:"composite_risk_class,omitempty"` - StepVerdicts []TraceStepVerdict `json:"step_verdicts,omitempty"` - PreApproved bool `json:"pre_approved,omitempty"` - PatternID string `json:"pattern_id,omitempty"` - RegistryReason string `json:"registry_reason,omitempty"` - Violations []string `json:"violations,omitempty"` - LatencyMS float64 `json:"latency_ms,omitempty"` - ApprovalTokenRef string `json:"approval_token_ref,omitempty"` - DelegationRef *DelegationRef `json:"delegation_ref,omitempty"` - SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` - Signature *Signature `json:"signature,omitempty"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ObservedAt time.Time `json:"observed_at,omitempty"` + ProducerVersion string `json:"producer_version"` + TraceID string `json:"trace_id"` + EventID string `json:"event_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + ToolName string `json:"tool_name"` + ArgsDigest string `json:"args_digest"` + IntentDigest string `json:"intent_digest"` + PolicyDigest string `json:"policy_digest"` + PolicyID string `json:"policy_id,omitempty"` + PolicyVersion string `json:"policy_version,omitempty"` + MatchedRuleIDs []string `json:"matched_rule_ids,omitempty"` + Verdict string `json:"verdict"` + ContextSetDigest string `json:"context_set_digest,omitempty"` + ContextEvidenceMode string `json:"context_evidence_mode,omitempty"` + ContextRefCount int `json:"context_ref_count,omitempty"` + ContextSource string `json:"context_source,omitempty"` + Script bool `json:"script,omitempty"` + StepCount int `json:"step_count,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + CompositeRiskClass string `json:"composite_risk_class,omitempty"` + StepVerdicts []TraceStepVerdict `json:"step_verdicts,omitempty"` + PreApproved bool `json:"pre_approved,omitempty"` + PatternID string `json:"pattern_id,omitempty"` + RegistryReason string `json:"registry_reason,omitempty"` + Violations []string `json:"violations,omitempty"` + LatencyMS float64 `json:"latency_ms,omitempty"` + ApprovalTokenRef string `json:"approval_token_ref,omitempty"` + DelegationRef *DelegationRef `json:"delegation_ref,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` + Signature *Signature `json:"signature,omitempty"` } type TraceStepVerdict struct { @@ -53,21 +61,22 @@ type Signature struct { } type IntentRequest struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ProducerVersion string `json:"producer_version"` - ToolName string `json:"tool_name"` - Args map[string]any `json:"args"` - ArgsDigest string `json:"args_digest,omitempty"` - IntentDigest string `json:"intent_digest,omitempty"` - ScriptHash string `json:"script_hash,omitempty"` - Script *IntentScript `json:"script,omitempty"` - Targets []IntentTarget `json:"targets"` - ArgProvenance []IntentArgProvenance `json:"arg_provenance,omitempty"` - SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` - Delegation *IntentDelegation `json:"delegation,omitempty"` - Context IntentContext `json:"context"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + ToolName string `json:"tool_name"` + Args map[string]any `json:"args"` + ArgsDigest string `json:"args_digest,omitempty"` + IntentDigest string `json:"intent_digest,omitempty"` + ScriptHash string `json:"script_hash,omitempty"` + Script *IntentScript `json:"script,omitempty"` + Targets []IntentTarget `json:"targets"` + ArgProvenance []IntentArgProvenance `json:"arg_provenance,omitempty"` + SkillProvenance *SkillProvenance `json:"skill_provenance,omitempty"` + Delegation *IntentDelegation `json:"delegation,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + Context IntentContext `json:"context"` } type IntentScript struct { @@ -210,19 +219,20 @@ type DelegationAuditEntry struct { } type DelegationAuditRecord struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ProducerVersion string `json:"producer_version"` - TraceID string `json:"trace_id"` - ToolName string `json:"tool_name"` - IntentDigest string `json:"intent_digest"` - PolicyDigest string `json:"policy_digest"` - DelegationRequired bool `json:"delegation_required"` - ValidDelegations int `json:"valid_delegations"` - Delegated bool `json:"delegated"` - DelegationRef string `json:"delegation_ref,omitempty"` - Entries []DelegationAuditEntry `json:"entries"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + TraceID string `json:"trace_id"` + ToolName string `json:"tool_name"` + IntentDigest string `json:"intent_digest"` + PolicyDigest string `json:"policy_digest"` + DelegationRequired bool `json:"delegation_required"` + ValidDelegations int `json:"valid_delegations"` + Delegated bool `json:"delegated"` + DelegationRef string `json:"delegation_ref,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + Entries []DelegationAuditEntry `json:"entries"` } type ApprovalAuditEntry struct { @@ -236,19 +246,20 @@ type ApprovalAuditEntry struct { } type ApprovalAuditRecord struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ProducerVersion string `json:"producer_version"` - TraceID string `json:"trace_id"` - ToolName string `json:"tool_name"` - IntentDigest string `json:"intent_digest"` - PolicyDigest string `json:"policy_digest"` - RequiredApprovals int `json:"required_approvals"` - ValidApprovals int `json:"valid_approvals"` - Approved bool `json:"approved"` - Approvers []string `json:"approvers,omitempty"` - Entries []ApprovalAuditEntry `json:"entries"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + TraceID string `json:"trace_id"` + ToolName string `json:"tool_name"` + IntentDigest string `json:"intent_digest"` + PolicyDigest string `json:"policy_digest"` + RequiredApprovals int `json:"required_approvals"` + ValidApprovals int `json:"valid_approvals"` + Approved bool `json:"approved"` + Approvers []string `json:"approvers,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + Entries []ApprovalAuditEntry `json:"entries"` } type BrokerCredentialRecord struct { diff --git a/core/schema/v1/runpack/types.go b/core/schema/v1/runpack/types.go index aa3840f..d8b25fa 100644 --- a/core/schema/v1/runpack/types.go +++ b/core/schema/v1/runpack/types.go @@ -1,6 +1,10 @@ package runpack -import "time" +import ( + "time" + + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" +) type Manifest struct { SchemaID string `json:"schema_id"` @@ -43,9 +47,10 @@ type RunEnv struct { } type TimelineEvt struct { - Event string `json:"event"` - TS time.Time `json:"ts"` - Ref string `json:"ref,omitempty"` + Event string `json:"event"` + TS time.Time `json:"ts"` + Ref string `json:"ref,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` } type IntentRecord struct { @@ -111,42 +116,47 @@ type SessionJournal struct { } type SessionEvent struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ProducerVersion string `json:"producer_version"` - SessionID string `json:"session_id"` - RunID string `json:"run_id"` - Sequence int64 `json:"sequence"` - IntentID string `json:"intent_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - IntentDigest string `json:"intent_digest,omitempty"` - PolicyDigest string `json:"policy_digest,omitempty"` - TraceID string `json:"trace_id,omitempty"` - TracePath string `json:"trace_path,omitempty"` - Verdict string `json:"verdict,omitempty"` - ReasonCodes []string `json:"reason_codes,omitempty"` - Violations []string `json:"violations,omitempty"` - SafetyInvariantVersion string `json:"safety_invariant_version,omitempty"` - SafetyInvariantHash string `json:"safety_invariant_hash,omitempty"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + Sequence int64 `json:"sequence"` + IntentID string `json:"intent_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + IntentDigest string `json:"intent_digest,omitempty"` + PolicyDigest string `json:"policy_digest,omitempty"` + PolicyID string `json:"policy_id,omitempty"` + PolicyVersion string `json:"policy_version,omitempty"` + MatchedRuleIDs []string `json:"matched_rule_ids,omitempty"` + TraceID string `json:"trace_id,omitempty"` + TracePath string `json:"trace_path,omitempty"` + Verdict string `json:"verdict,omitempty"` + ReasonCodes []string `json:"reason_codes,omitempty"` + Violations []string `json:"violations,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + SafetyInvariantVersion string `json:"safety_invariant_version,omitempty"` + SafetyInvariantHash string `json:"safety_invariant_hash,omitempty"` } type SessionCheckpoint struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - ProducerVersion string `json:"producer_version"` - SessionID string `json:"session_id"` - RunID string `json:"run_id"` - CheckpointIndex int `json:"checkpoint_index"` - SequenceStart int64 `json:"sequence_start"` - SequenceEnd int64 `json:"sequence_end"` - RunpackPath string `json:"runpack_path"` - ManifestDigest string `json:"manifest_digest"` - PrevCheckpointDigest string `json:"prev_checkpoint_digest,omitempty"` - CheckpointDigest string `json:"checkpoint_digest"` - SafetyInvariantVersion string `json:"safety_invariant_version,omitempty"` - SafetyInvariantHash string `json:"safety_invariant_hash,omitempty"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + ProducerVersion string `json:"producer_version"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + CheckpointIndex int `json:"checkpoint_index"` + SequenceStart int64 `json:"sequence_start"` + SequenceEnd int64 `json:"sequence_end"` + RunpackPath string `json:"runpack_path"` + ManifestDigest string `json:"manifest_digest"` + PrevCheckpointDigest string `json:"prev_checkpoint_digest,omitempty"` + CheckpointDigest string `json:"checkpoint_digest"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` + SafetyInvariantVersion string `json:"safety_invariant_version,omitempty"` + SafetyInvariantHash string `json:"safety_invariant_hash,omitempty"` } type SessionChain struct { diff --git a/core/schema/v1/scout/types.go b/core/schema/v1/scout/types.go index 190a95d..5d75e8b 100644 --- a/core/schema/v1/scout/types.go +++ b/core/schema/v1/scout/types.go @@ -1,6 +1,10 @@ package scout -import "time" +import ( + "time" + + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" +) type InventorySnapshot struct { SchemaID string `json:"schema_id"` @@ -13,13 +17,14 @@ type InventorySnapshot struct { } type InventoryItem struct { - ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - Locator string `json:"locator"` - RiskLevel string `json:"risk_level,omitempty"` - Tags []string `json:"tags,omitempty"` - LastSeenRun string `json:"last_seen_run,omitempty"` + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Locator string `json:"locator"` + RiskLevel string `json:"risk_level,omitempty"` + Tags []string `json:"tags,omitempty"` + LastSeenRun string `json:"last_seen_run,omitempty"` + Relationship *schemacommon.RelationshipEnvelope `json:"relationship,omitempty"` } type AdoptionEvent struct { diff --git a/core/scout/scout_test.go b/core/scout/scout_test.go index eadd23e..ce7b932 100644 --- a/core/scout/scout_test.go +++ b/core/scout/scout_test.go @@ -45,6 +45,14 @@ def list_orders(): if len(snapshot.Items) < 4 { t.Fatalf("expected at least 4 inventory items, got %d", len(snapshot.Items)) } + for _, item := range snapshot.Items { + if item.Relationship == nil { + t.Fatalf("expected inventory relationship envelope on item: %#v", item) + } + if item.Relationship.ParentRef == nil || item.Relationship.ParentRef.Kind != "evidence" || item.Relationship.ParentRef.ID != snapshot.SnapshotID { + t.Fatalf("unexpected inventory relationship parent_ref: %#v", item.Relationship.ParentRef) + } + } policyPath := filepath.Join(workDir, "policy.yaml") policySource := `default_verdict: block diff --git a/core/scout/snapshot.go b/core/scout/snapshot.go index e7949f7..b57c24f 100644 --- a/core/scout/snapshot.go +++ b/core/scout/snapshot.go @@ -14,6 +14,7 @@ import ( "strings" "time" + schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" schemascout "github.com/Clyra-AI/gait/core/schema/v1/scout" jcs "github.com/Clyra-AI/proof/canon" "github.com/goccy/go-yaml" @@ -63,6 +64,7 @@ func (provider DefaultProvider) Snapshot(_ context.Context, req SnapshotRequest) if err != nil { return schemascout.InventorySnapshot{}, fmt.Errorf("compute snapshot id: %w", err) } + snapshotItems = attachInventoryRelationships(snapshotID, snapshotItems) return schemascout.InventorySnapshot{ SchemaID: "gait.scout.inventory_snapshot", @@ -464,6 +466,135 @@ func sortInventoryItems(items []schemascout.InventoryItem) { }) } +func attachInventoryRelationships(snapshotID string, items []schemascout.InventoryItem) []schemascout.InventoryItem { + normalizedSnapshotID := strings.TrimSpace(snapshotID) + for index := range items { + item := items[index] + relationship := schemacommon.RelationshipEnvelope{ + EntityRefs: normalizeInventoryRelationshipRefs([]schemacommon.RelationshipRef{ + {Kind: "tool", ID: strings.TrimSpace(item.ID)}, + {Kind: "resource", ID: strings.TrimSpace(item.Locator)}, + }), + Edges: normalizeInventoryRelationshipEdges([]schemacommon.RelationshipEdge{ + { + Kind: "derived_from", + From: schemacommon.RelationshipNodeRef{Kind: "tool", ID: strings.TrimSpace(item.ID)}, + To: schemacommon.RelationshipNodeRef{Kind: "resource", ID: strings.TrimSpace(item.Locator)}, + }, + }), + ParentRecordID: normalizedSnapshotID, + } + if normalizedSnapshotID != "" { + relationship.ParentRef = &schemacommon.RelationshipNodeRef{Kind: "evidence", ID: normalizedSnapshotID} + } + relationship.RelatedEntityIDs = inventoryRelationshipRefIDs(relationship.EntityRefs) + if relationship.ParentRef == nil && len(relationship.EntityRefs) == 0 && len(relationship.Edges) == 0 { + item.Relationship = nil + } else { + item.Relationship = &relationship + } + items[index] = item + } + return items +} + +func normalizeInventoryRelationshipRefs(refs []schemacommon.RelationshipRef) []schemacommon.RelationshipRef { + if len(refs) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipRef, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + kind := strings.ToLower(strings.TrimSpace(ref.Kind)) + id := strings.TrimSpace(ref.ID) + if kind == "" || id == "" { + continue + } + if kind != "agent" && kind != "tool" && kind != "resource" && kind != "policy" && kind != "run" && kind != "trace" && kind != "delegation" && kind != "evidence" { + continue + } + key := kind + "\x00" + id + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipRef{Kind: kind, ID: id}) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + return normalized[i].ID < normalized[j].ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeInventoryRelationshipEdges(edges []schemacommon.RelationshipEdge) []schemacommon.RelationshipEdge { + if len(edges) == 0 { + return nil + } + normalized := make([]schemacommon.RelationshipEdge, 0, len(edges)) + seen := map[string]struct{}{} + for _, edge := range edges { + kind := strings.ToLower(strings.TrimSpace(edge.Kind)) + fromKind := strings.ToLower(strings.TrimSpace(edge.From.Kind)) + fromID := strings.TrimSpace(edge.From.ID) + toKind := strings.ToLower(strings.TrimSpace(edge.To.Kind)) + toID := strings.TrimSpace(edge.To.ID) + if kind == "" || fromKind == "" || fromID == "" || toKind == "" || toID == "" { + continue + } + if kind != "delegates_to" && kind != "calls" && kind != "governed_by" && kind != "targets" && kind != "derived_from" && kind != "emits_evidence" { + continue + } + key := kind + "\x00" + fromKind + "\x00" + fromID + "\x00" + toKind + "\x00" + toID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, schemacommon.RelationshipEdge{ + Kind: kind, + From: schemacommon.RelationshipNodeRef{Kind: fromKind, ID: fromID}, + To: schemacommon.RelationshipNodeRef{Kind: toKind, ID: toID}, + }) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].Kind != normalized[j].Kind { + return normalized[i].Kind < normalized[j].Kind + } + if normalized[i].From.Kind != normalized[j].From.Kind { + return normalized[i].From.Kind < normalized[j].From.Kind + } + if normalized[i].From.ID != normalized[j].From.ID { + return normalized[i].From.ID < normalized[j].From.ID + } + if normalized[i].To.Kind != normalized[j].To.Kind { + return normalized[i].To.Kind < normalized[j].To.Kind + } + return normalized[i].To.ID < normalized[j].To.ID + }) + if len(normalized) == 0 { + return nil + } + return normalized +} + +func inventoryRelationshipRefIDs(refs []schemacommon.RelationshipRef) []string { + if len(refs) == 0 { + return nil + } + ids := make([]string, 0, len(refs)) + for _, ref := range refs { + if id := strings.TrimSpace(ref.ID); id != "" { + ids = append(ids, id) + } + } + return uniqueSorted(ids) +} + func normalizeIdentifier(value string) string { lower := strings.ToLower(strings.TrimSpace(value)) var builder strings.Builder diff --git a/docs/contracts/artifact_graph.md b/docs/contracts/artifact_graph.md index 01508d3..da9c867 100644 --- a/docs/contracts/artifact_graph.md +++ b/docs/contracts/artifact_graph.md @@ -23,6 +23,17 @@ Applies to: - Cross-artifact references SHOULD use immutable identifiers (digest, run_id, trace_id) and MUST NOT rely on mutable display names. - Producers MUST preserve deterministic serialization for stable verification and diff behavior. - Consumers MUST treat unknown additive fields as non-breaking within major `v1.x`. +- Producers MAY include a standardized optional `relationship` envelope to emit graph-ready topology with: + - `parent_ref` + - `entity_refs[]` + - `policy_ref` + - `agent_chain[]` + - `edges[]` +- Relationship envelope values MUST be deterministic when present: + - lowercase digest identifiers + - deduplicated arrays + - stable sort order for refs and edges + - UTC/RFC3339 for timestamps already carried in parent artifacts ## Graph Integrity Expectations @@ -35,6 +46,10 @@ Applies to: - A `RegressResult` SHOULD reference fixture/run identity so failures can map back to captured artifacts. - Evidence bundles SHOULD include pointers back to the exact runpack/trace/regress artifacts they summarize. - Delegation audits SHOULD reference the trace and delegation token IDs used for allow/block outcomes. +- Trace and audit producers SHOULD include `relationship` envelopes when enough local context exists to bind: + - actor -> tool (`calls`) + - tool -> policy (`governed_by`) + - delegator -> delegate (`delegates_to`) - Consumer projections SHOULD preserve intent/receipt digest continuity (`intent_digest`, `policy_digest`, `refs.context_set_digest`, `refs.receipts[*].{query_digest,content_digest}`) when deriving audit views. ## Compatibility Model diff --git a/docs/contracts/primitive_contract.md b/docs/contracts/primitive_contract.md index b45bd8e..7b5dc71 100644 --- a/docs/contracts/primitive_contract.md +++ b/docs/contracts/primitive_contract.md @@ -28,6 +28,31 @@ Intent + receipt continuity conformance profile: - Semantic changes to required fields MUST NOT occur without a version bump. - Optional fields MAY be added in minor/patch versions if consumers can ignore unknown fields safely. +## Standard Relationship Envelope (`relationship`, optional) + +Purpose: emit graph-ready topology without changing required primitive contracts. + +Fields (all optional): + +- `parent_ref` (`kind`, `id`) +- `entity_refs[]` (`kind`, `id`) +- `policy_ref` (`policy_id`, `policy_version`, `policy_digest`, `matched_rule_ids[]`) +- `agent_chain[]` (`identity`, `role`) +- `edges[]` (`kind`, `from`, `to`) + +Producer obligations when present: + +- MUST keep `relationship` additive-only within `v1.x`. +- MUST normalize digest identifiers to lowercase. +- MUST deduplicate and deterministically sort `entity_refs` and `edges`. +- MUST include relationship fields in canonical JSON hashing/signing naturally (no out-of-band exclusion). + +Consumer obligations: + +- MUST tolerate absence of `relationship`. +- MUST ignore unknown additive fields within `relationship` in `v1.x` readers. +- MUST NOT treat relationship presence as required for base primitive validity. + ## IntentRequest (`gait.gate.intent_request`, `1.0.0`) Purpose: normalized tool-call intent at the execution boundary. @@ -144,6 +169,7 @@ Producer obligations: - `context_set_digest` - `context_evidence_mode` - `context_ref_count` +- MAY carry standardized graph links in `relationship` for topology queries. Consumer obligations: diff --git a/go.mod b/go.mod index c8d53b5..207c530 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Clyra-AI/gait go 1.25.7 require ( - github.com/Clyra-AI/proof v0.4.4 + github.com/Clyra-AI/proof v0.4.5 github.com/goccy/go-yaml v1.19.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 02af93c..f4fb1b4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Clyra-AI/proof v0.4.4 h1:JHMPthKX+2Ud3pRWSd13LKHkS04uDXHizna7T1D2mpw= -github.com/Clyra-AI/proof v0.4.4/go.mod h1:EDff6buidj222E+EYyqQXXj1rtPgSFlYOxl2JFfWKFs= +github.com/Clyra-AI/proof v0.4.5 h1:6l25eL8rh5x5ZTmyXdcc33kluaC3TJDWDPR5pG/cdow= +github.com/Clyra-AI/proof v0.4.5/go.mod h1:EDff6buidj222E+EYyqQXXj1rtPgSFlYOxl2JFfWKFs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/schemas/v1/common/relationship_envelope.schema.json b/schemas/v1/common/relationship_envelope.schema.json new file mode 100644 index 0000000..8f79fd5 --- /dev/null +++ b/schemas/v1/common/relationship_envelope.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + } + }, + "entity_refs": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + } + } + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "agent_chain": { + "type": "array", + "items": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + } + } + }, + "edges": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "related_entity_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "agent_lineage": { + "type": "array", + "items": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/schemas/v1/gate/approval_audit_record.schema.json b/schemas/v1/gate/approval_audit_record.schema.json index 516cf85..c1c340b 100644 --- a/schemas/v1/gate/approval_audit_record.schema.json +++ b/schemas/v1/gate/approval_audit_record.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/gate/approval_audit_record.schema.json", "title": "Approval Audit Record", "type": "object", "required": [ @@ -33,6 +32,7 @@ "type": "array", "items": { "type": "string", "minLength": 1 } }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "entries": { "type": "array", "items": { @@ -54,5 +54,130 @@ } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/gate/delegation_audit_record.schema.json b/schemas/v1/gate/delegation_audit_record.schema.json index 647776a..3c528c7 100644 --- a/schemas/v1/gate/delegation_audit_record.schema.json +++ b/schemas/v1/gate/delegation_audit_record.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/gate/delegation_audit_record.schema.json", "title": "Delegation Audit Record", "type": "object", "required": [ @@ -30,6 +29,7 @@ "valid_delegations": { "type": "integer", "minimum": 0 }, "delegated": { "type": "boolean" }, "delegation_ref": { "type": "string" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "entries": { "type": "array", "items": { @@ -48,5 +48,130 @@ } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/gate/intent_request.schema.json b/schemas/v1/gate/intent_request.schema.json index c22b1ad..31a94ec 100644 --- a/schemas/v1/gate/intent_request.schema.json +++ b/schemas/v1/gate/intent_request.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/gate/intent_request.schema.json", "title": "Gate Intent Request", "type": "object", "required": [ @@ -199,6 +198,7 @@ }, "additionalProperties": false }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "context": { "type": "object", "required": ["identity", "workspace", "risk_class"], @@ -229,5 +229,130 @@ "additionalProperties": true } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/gate/trace_record.schema.json b/schemas/v1/gate/trace_record.schema.json index 925f7cd..dd4278f 100644 --- a/schemas/v1/gate/trace_record.schema.json +++ b/schemas/v1/gate/trace_record.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/gate/trace_record.schema.json", "title": "Gate Trace Record", "type": "object", "required": [ @@ -28,6 +27,12 @@ "args_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "intent_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "policy_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, "verdict": { "type": "string", "enum": ["allow", "block", "dry_run", "require_approval"] }, "context_set_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "context_evidence_mode": { "type": "string", "enum": ["best_effort", "required"] }, @@ -84,6 +89,7 @@ }, "additionalProperties": false }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "signature": { "type": "object", "required": ["alg", "key_id", "sig"], @@ -96,5 +102,130 @@ "additionalProperties": false } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/runpack/run.schema.json b/schemas/v1/runpack/run.schema.json index b78a18c..3427620 100644 --- a/schemas/v1/runpack/run.schema.json +++ b/schemas/v1/runpack/run.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/runpack/run.schema.json", "title": "Runpack Run", "type": "object", "required": [ @@ -36,11 +35,137 @@ "properties": { "event": { "type": "string" }, "ts": { "type": "string", "format": "date-time" }, - "ref": { "type": "string" } + "ref": { "type": "string" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" } }, "additionalProperties": true } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/runpack/session_chain.schema.json b/schemas/v1/runpack/session_chain.schema.json index 5c4050e..749de70 100644 --- a/schemas/v1/runpack/session_chain.schema.json +++ b/schemas/v1/runpack/session_chain.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/runpack/session_chain.schema.json", "title": "Runpack Session Chain", "type": "object", "required": [ @@ -51,11 +50,137 @@ "runpack_path": { "type": "string", "minLength": 1 }, "manifest_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "prev_checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, - "checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } + "checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" } }, "additionalProperties": false } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/runpack/session_checkpoint.schema.json b/schemas/v1/runpack/session_checkpoint.schema.json index 65c599b..72187cf 100644 --- a/schemas/v1/runpack/session_checkpoint.schema.json +++ b/schemas/v1/runpack/session_checkpoint.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/runpack/session_checkpoint.schema.json", "title": "Runpack Session Checkpoint", "type": "object", "required": [ @@ -30,7 +29,133 @@ "runpack_path": { "type": "string", "minLength": 1 }, "manifest_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "prev_checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, - "checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } + "checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" } + }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } }, "additionalProperties": false } diff --git a/schemas/v1/runpack/session_journal.schema.json b/schemas/v1/runpack/session_journal.schema.json index 7cc6f3b..f8785d9 100644 --- a/schemas/v1/runpack/session_journal.schema.json +++ b/schemas/v1/runpack/session_journal.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/runpack/session_journal.schema.json", "title": "Runpack Session Journal", "type": "object", "required": [ @@ -46,11 +45,18 @@ "tool_name": { "type": "string" }, "intent_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "policy_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, "trace_id": { "type": "string" }, "trace_path": { "type": "string" }, "verdict": { "type": "string", "enum": ["allow", "block", "dry_run", "require_approval"] }, "reason_codes": { "type": "array", "items": { "type": "string" } }, "violations": { "type": "array", "items": { "type": "string" } }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "safety_invariant_version": { "type": "string", "minLength": 1 }, "safety_invariant_hash": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } }, @@ -89,6 +95,7 @@ "manifest_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "prev_checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "checkpoint_digest": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" }, "safety_invariant_version": { "type": "string", "minLength": 1 }, "safety_invariant_hash": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } }, @@ -96,5 +103,130 @@ } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/v1/scout/inventory_snapshot.schema.json b/schemas/v1/scout/inventory_snapshot.schema.json index ebab84f..ee7d905 100644 --- a/schemas/v1/scout/inventory_snapshot.schema.json +++ b/schemas/v1/scout/inventory_snapshot.schema.json @@ -1,6 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gait.dev/schemas/v1/scout/inventory_snapshot.schema.json", "title": "Scout Inventory Snapshot", "type": "object", "required": [ @@ -30,11 +29,137 @@ "locator": { "type": "string" }, "risk_level": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "tags": { "type": "array", "items": { "type": "string" } }, - "last_seen_run": { "type": "string", "pattern": "^run_[A-Za-z0-9_-]+$" } + "last_seen_run": { "type": "string", "pattern": "^run_[A-Za-z0-9_-]+$" }, + "relationship": { "$ref": "#/$defs/relationship_envelope" } }, "additionalProperties": false } } }, + "$defs": { + "relationship_parent_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["trace", "run", "session", "intent", "policy", "agent", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_entity_ref": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["agent", "tool", "resource", "policy", "run", "trace", "delegation", "evidence"] + }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "policy_ref": { + "type": "object", + "properties": { + "policy_id": { "type": "string", "minLength": 1 }, + "policy_version": { "type": "string", "minLength": 1 }, + "policy_digest": { "type": "string", "pattern": "^(sha256:)?[a-f0-9]{64}$" }, + "matched_rule_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "agent_link": { + "type": "object", + "required": ["identity", "role"], + "properties": { + "identity": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "enum": ["requester", "delegator", "delegate"] } + }, + "additionalProperties": false + }, + "relationship_edge": { + "type": "object", + "required": ["kind", "from", "to"], + "properties": { + "kind": { + "type": "string", + "enum": ["delegates_to", "calls", "governed_by", "targets", "derived_from", "emits_evidence"] + }, + "from": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "to": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agent_lineage": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string", "minLength": 1 }, + "delegated_by": { "type": "string", "minLength": 1 }, + "delegation_record_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "relationship_envelope": { + "type": "object", + "properties": { + "parent_ref": { "$ref": "#/$defs/relationship_parent_ref" }, + "entity_refs": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_entity_ref" }, + "uniqueItems": true + }, + "policy_ref": { "$ref": "#/$defs/policy_ref" }, + "agent_chain": { + "type": "array", + "items": { "$ref": "#/$defs/agent_link" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/relationship_edge" }, + "uniqueItems": true + }, + "parent_record_id": { "type": "string", "minLength": 1 }, + "related_record_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "related_entity_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "agent_lineage": { + "type": "array", + "items": { "$ref": "#/$defs/agent_lineage" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/scripts/test_ent_consumer_contract.sh b/scripts/test_ent_consumer_contract.sh index cbb69d1..2a8d0e4 100644 --- a/scripts/test_ent_consumer_contract.sh +++ b/scripts/test_ent_consumer_contract.sh @@ -239,6 +239,21 @@ require("endpoint_destructive_operation" in (endpoint_approval.get("reason_codes # Enterprise consumers must tolerate additive unknown fields. trace_with_extension = dict(trace_record) trace_with_extension["enterprise_extension"] = {"opaque": True} +trace_with_extension["relationship"] = { + "parent_ref": {"kind": "session", "id": "sess_demo"}, + "entity_refs": [ + {"kind": "agent", "id": "agent.demo"}, + {"kind": "tool", "id": trace_record.get("tool_name", "")}, + ], + "policy_ref": {"policy_digest": trace_record.get("policy_digest", "")}, + "edges": [ + { + "kind": "governed_by", + "from": {"kind": "tool", "id": trace_record.get("tool_name", "")}, + "to": {"kind": "policy", "id": trace_record.get("policy_digest", "")}, + } + ], +} regress_with_extension = dict(regress_result) regress_with_extension["enterprise_extension"] = "v2" signal_with_extension = dict(signal_report)