// Package jclawhydra is the Go reference implementation of the // JouleClaw third-generation Hydra client. // // Parses any Hydra ApiDocumentation, validates a candidate input // against the linked SHACL shape graph, dispatches via a pluggable // transport, and returns a CallReport with BLAKE3 hashes over // canonical-JSON of the input/output payloads. // // Produces byte-for-byte identical CallReport hashes against the // Rust reference client (`jouleclaw-hydra-client`) and the Python // reference client (`clients/python/jclaw_hydra/`). All three are // validated against the same vectors in // `conformance/call-reports.json`. package jclawhydra import ( "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strings" "lukechampine.com/blake3" ) // JCNamespace is the canonical IRI prefix for the JouleClaw v1 // vocabulary. const JCNamespace = "https://jouleclaw.transaction.science/vocab/v1#" // ───────────────────────────────────────────────────────────────────── // Hydra surface // ───────────────────────────────────────────────────────────────────── // Operation mirrors jouleclaw_hydra::Operation on the wire. type Operation struct { ID string `json:"@id"` Types []string `json:"@type"` Title string `json:"title"` Description string `json:"description"` Method string `json:"method"` Expects string `json:"expects"` Returns string `json:"returns"` StatusCodes []map[string]interface{} `json:"statusCodes"` JCToolID *string `json:"jc_tool_id,omitempty"` JCMutating *bool `json:"jc_mutating,omitempty"` } // Class is a hydra:Class — groups operations. type Class struct { ID string `json:"@id"` Title string `json:"title"` Description string `json:"description"` SupportedOperation []Operation `json:"supportedOperation"` } // ApiDocumentation is the root Hydra document. type ApiDocumentation struct { ID string `json:"@id"` Title string `json:"title"` Description string `json:"description"` SupportedClass []Class `json:"supportedClass"` Entrypoint *string `json:"entrypoint,omitempty"` Context interface{} `json:"@context,omitempty"` } // ParseAPI parses a Hydra ApiDocumentation from JSON-LD bytes. func ParseAPI(b []byte) (*ApiDocumentation, error) { var api ApiDocumentation if err := json.Unmarshal(b, &api); err != nil { return nil, fmt.Errorf("parse api: %w", err) } return &api, nil } // ───────────────────────────────────────────────────────────────────── // SHACL Core validator (minimal subset, mirrors jouleclaw-shacl) // ───────────────────────────────────────────────────────────────────── // Datatype enumerates the xsd:* types the validator handles. type Datatype string const ( DTString Datatype = "string" DTInteger Datatype = "integer" DTNonNegativeInteger Datatype = "non_negative_integer" DTDouble Datatype = "double" DTBoolean Datatype = "boolean" DTDateTime Datatype = "datetime" ) // NodeKind enumerates the SHACL Core node-kind constraints. type NodeKind string const ( NKIRI NodeKind = "IRI" NKLiteral NodeKind = "Literal" NKBlankNodeOrIRI NodeKind = "BlankNodeOrIRI" ) // PropertyShape constrains one property. type PropertyShape struct { Path string MinCount *int MaxCount *int Datatype *Datatype NodeKind *NodeKind In []interface{} Pattern *string MinInclusive *float64 MaxInclusive *float64 Node string } // NodeShape constrains an instance of TargetClass. type NodeShape struct { TargetClass string Properties []PropertyShape } // ShapeGraph holds the registered shapes keyed by TargetClass. type ShapeGraph struct { shapes map[string]NodeShape } // NewShapeGraph returns an empty graph. func NewShapeGraph() *ShapeGraph { return &ShapeGraph{shapes: map[string]NodeShape{}} } // Add registers a NodeShape on the graph. func (g *ShapeGraph) Add(s NodeShape) *ShapeGraph { g.shapes[s.TargetClass] = s return g } // Get returns the registered shape for targetClass, or nil. func (g *ShapeGraph) Get(targetClass string) *NodeShape { if s, ok := g.shapes[targetClass]; ok { return &s } return nil } // Violation is one constraint failure on a candidate document. type Violation struct { Shape string `json:"shape"` Path string `json:"path"` Message string `json:"message"` } // ValidationReport is the output of SHACL validation. type ValidationReport struct { Conforms bool `json:"conforms"` Violations []Violation `json:"violations"` } // Validate runs the shape graph against value at targetClass. func Validate(g *ShapeGraph, targetClass string, value interface{}) (*ValidationReport, error) { shape := g.Get(targetClass) if shape == nil { return nil, fmt.Errorf("no shape registered for %s", targetClass) } r := &ValidationReport{Conforms: true, Violations: []Violation{}} validateAgainst(*shape, value, g, r) r.Conforms = len(r.Violations) == 0 return r, nil } func validateAgainst(shape NodeShape, value interface{}, g *ShapeGraph, r *ValidationReport) { obj, ok := value.(map[string]interface{}) if !ok { r.Violations = append(r.Violations, Violation{Shape: shape.TargetClass, Path: "", Message: fmt.Sprintf("expected object, got %T", value)}) return } for _, p := range shape.Properties { v, present := obj[p.Path] if !present || v == nil { if p.MinCount != nil && *p.MinCount > 0 { r.Violations = append(r.Violations, Violation{Shape: shape.TargetClass, Path: p.Path, Message: fmt.Sprintf("missing required property `%s`", p.Path)}) } continue } checkProperty(p, v, shape, g, r) } } func checkProperty(p PropertyShape, v interface{}, parent NodeShape, g *ShapeGraph, r *ValidationReport) { if arr, isList := v.([]interface{}); isList { n := len(arr) if p.MinCount != nil && n < *p.MinCount { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("min_count %d not met (%d present)", *p.MinCount, n))) } if p.MaxCount != nil && n > *p.MaxCount { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("max_count %d exceeded (%d present)", *p.MaxCount, n))) } for _, item := range arr { checkValue(p, item, parent, g, r) } return } checkValue(p, v, parent, g, r) } func checkValue(p PropertyShape, v interface{}, parent NodeShape, g *ShapeGraph, r *ValidationReport) { if p.NodeKind != nil { switch *p.NodeKind { case NKIRI: if _, ok := v.(string); !ok { r.Violations = append(r.Violations, viol(parent, p, "node_kind IRI not satisfied")) } case NKLiteral: switch v.(type) { case string, float64, bool: default: r.Violations = append(r.Violations, viol(parent, p, "node_kind Literal not satisfied")) } case NKBlankNodeOrIRI: switch v.(type) { case map[string]interface{}, string: default: r.Violations = append(r.Violations, viol(parent, p, "node_kind BlankNodeOrIRI not satisfied")) } } } if p.Datatype != nil && !matchesDatatype(*p.Datatype, v) { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("datatype %s not satisfied", *p.Datatype))) } if p.In != nil && !inSet(p.In, v) { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("value not in allowed set (%d options)", len(p.In)))) } if p.Pattern != nil { if s, ok := v.(string); ok { re, err := regexp.Compile(*p.Pattern) if err != nil { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("bad shape pattern: %s", err))) } else if !re.MatchString(s) { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("pattern `%s` not matched", *p.Pattern))) } } } if f, ok := v.(float64); ok { if p.MinInclusive != nil && f < *p.MinInclusive { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("min_inclusive %v not met (%v)", *p.MinInclusive, f))) } if p.MaxInclusive != nil && f > *p.MaxInclusive { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("max_inclusive %v exceeded (%v)", *p.MaxInclusive, f))) } } if p.Node != "" { nested := g.Get(p.Node) if nested == nil { r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("nested shape `%s` not in graph", p.Node))) } else { validateAgainst(*nested, v, g, r) } } } func matchesDatatype(dt Datatype, v interface{}) bool { switch dt { case DTString: _, ok := v.(string) return ok case DTInteger, DTNonNegativeInteger, DTDouble: // JSON numbers come in as float64. NonNegativeInteger checks >= 0. f, ok := v.(float64) if !ok { return false } if dt == DTNonNegativeInteger && f < 0 { return false } return true case DTBoolean: _, ok := v.(bool) return ok case DTDateTime: s, ok := v.(string) if !ok || len(s) < 19 { return false } return looksLikeRFC3339(s) } return false } func looksLikeRFC3339(s string) bool { re := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}`) return re.MatchString(s) } func inSet(allowed []interface{}, v interface{}) bool { for _, x := range allowed { if equal(x, v) { return true } } return false } func equal(a, b interface{}) bool { switch av := a.(type) { case string: bv, ok := b.(string) return ok && av == bv case float64: bv, ok := b.(float64) return ok && av == bv case bool: bv, ok := b.(bool) return ok && av == bv } return false } func viol(parent NodeShape, p PropertyShape, msg string) Violation { return Violation{Shape: parent.TargetClass, Path: p.Path, Message: msg} } // ───────────────────────────────────────────────────────────────────── // HydraClient + Transport + CallReport // ───────────────────────────────────────────────────────────────────── // TransportResponse is what a Transport returns to the client. type TransportResponse struct { Status int Body interface{} // JoulesUJ is the transport's figure for the dispatch's energy, if it // has one. nil: none, and the client emits a zero graded "Estimator". JoulesUJ *uint64 // JoulesProvenance is the grade of the meter that produced JoulesUJ: // "HwShunt" only for a hardware reading, "ModelBased", or "Estimator". // Empty (or any other value) is sealed "Estimator": the client cannot // tell how a transport obtained a figure, and a figure must not claim // better provenance than how it was obtained. JoulesProvenance string } // GradedJoules is the figure and grade the client seals: the transport's // figure at its stated grade, an ungraded figure as an estimate, and no // figure as a zero estimate. Matches TransportResponse::graded_joules. func (r *TransportResponse) GradedJoules() (uint64, string) { if r.JoulesUJ == nil { return 0, "Estimator" } switch r.JoulesProvenance { case "HwShunt", "ModelBased", "Estimator": return *r.JoulesUJ, r.JoulesProvenance default: return *r.JoulesUJ, "Estimator" } } // Transport pluggable transport interface. type Transport interface { Dispatch(method, operationID string, input interface{}) (*TransportResponse, error) } // MockTransport — echo-mode reference; matches jouleclaw_hydra_client::MockTransport byte for byte. type MockTransport struct { canned map[string]*TransportResponse } // NewMockTransport returns an empty mock transport in echo mode. func NewMockTransport() *MockTransport { return &MockTransport{canned: map[string]*TransportResponse{}} } // Respond registers a canned response for a given operation id. func (m *MockTransport) Respond(opID string, r *TransportResponse) *MockTransport { m.canned[opID] = r return m } // Dispatch implements Transport. func (m *MockTransport) Dispatch(_method, operationID string, input interface{}) (*TransportResponse, error) { if r, ok := m.canned[operationID]; ok { return r, nil } return &TransportResponse{ Status: 200, Body: map[string]interface{}{"echo": input}, }, nil } // CallReport is the per-call outcome a downstream receipt stream records. type CallReport struct { ToolID string `json:"tool_id"` Status int `json:"status"` InputHash string `json:"input_hash"` OutputHash string `json:"output_hash"` StatusMatchesContract bool `json:"status_matches_contract"` Body interface{} `json:"body"` Touch map[string]interface{} `json:"touch"` } // HydraClient parses + validates + dispatches. type HydraClient struct { API *ApiDocumentation Shapes *ShapeGraph } // NewClient builds a HydraClient from an ApiDocumentation plus an optional ShapeGraph. func NewClient(api *ApiDocumentation, shapes *ShapeGraph) *HydraClient { if shapes == nil { shapes = NewShapeGraph() } return &HydraClient{API: api, Shapes: shapes} } // Operations lists every operation across every supported class. func (c *HydraClient) Operations() []Operation { var out []Operation for _, cls := range c.API.SupportedClass { out = append(out, cls.SupportedOperation...) } return out } // FindOperation looks up an operation by jc_tool_id, @id, or title. func (c *HydraClient) FindOperation(name string) *Operation { for _, op := range c.Operations() { op := op if (op.JCToolID != nil && *op.JCToolID == name) || op.ID == name || op.Title == name { return &op } } return nil } // ValidateInput checks an input against the operation's expects-class shape. func (c *HydraClient) ValidateInput(op *Operation, value interface{}) error { if strings.HasSuffix(op.Expects, "#Nothing") { return nil } if c.Shapes.Get(op.Expects) == nil { return nil } r, err := Validate(c.Shapes, op.Expects, value) if err != nil { return err } if !r.Conforms { return &InputInvalidError{Shape: op.Expects, Report: r} } return nil } // InputInvalidError signals a SHACL validation failure on the agent side. type InputInvalidError struct { Shape string Report *ValidationReport } func (e *InputInvalidError) Error() string { return fmt.Sprintf("input does not validate against shape `%s` (%d violation(s))", e.Shape, len(e.Report.Violations)) } // Call dispatches op against transport — validates first; on failure // returns without ever calling the transport. func (c *HydraClient) Call(opName string, input interface{}, transport Transport) (*CallReport, error) { op := c.FindOperation(opName) if op == nil { return nil, fmt.Errorf("operation `%s` not found", opName) } if err := c.ValidateInput(op, input); err != nil { return nil, err } resp, err := transport.Dispatch(op.Method, op.ID, input) if err != nil { return nil, fmt.Errorf("transport: %w", err) } toolID := op.ID if op.JCToolID != nil { toolID = *op.JCToolID } inputHash := blake3Hex(CanonicalJSON(input)) outputHash := blake3Hex(CanonicalJSON(resp.Body)) matches := false for _, sc := range op.StatusCodes { if code, ok := sc["code"].(float64); ok && int(code) == resp.Status { matches = true break } } joules, provenance := resp.GradedJoules() return &CallReport{ ToolID: toolID, Status: resp.Status, InputHash: inputHash, OutputHash: outputHash, StatusMatchesContract: matches, Body: resp.Body, Touch: map[string]interface{}{ "tool_id": toolID, "joules_uj": joules, "energy_provenance": provenance, }, }, nil } // ───────────────────────────────────────────────────────────────────── // Canonical JSON + BLAKE3 — bytes the three implementations agree on // ───────────────────────────────────────────────────────────────────── // CanonicalJSON returns a lexically-sorted, no-whitespace JSON // serialisation. Matches the Rust client's canonical form so the // BLAKE3 hash is identical across implementations. func CanonicalJSON(value interface{}) []byte { var buf strings.Builder writeCanonical(&buf, value) return []byte(buf.String()) } func writeCanonical(buf *strings.Builder, value interface{}) { switch v := value.(type) { case nil: buf.WriteString("null") case bool: if v { buf.WriteString("true") } else { buf.WriteString("false") } case float64: // JSON numbers — encoding/json will produce the same form // the Rust client emits for integers. b, _ := json.Marshal(v) buf.Write(b) case string: b, _ := json.Marshal(v) buf.Write(b) case []interface{}: buf.WriteString("[") for i, item := range v { if i > 0 { buf.WriteString(",") } writeCanonical(buf, item) } buf.WriteString("]") case map[string]interface{}: keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) } sort.Strings(keys) buf.WriteString("{") for i, k := range keys { if i > 0 { buf.WriteString(",") } b, _ := json.Marshal(k) buf.Write(b) buf.WriteString(":") writeCanonical(buf, v[k]) } buf.WriteString("}") default: // Fall back to json.Marshal for anything else — same shape // the Rust serde produces. b, _ := json.Marshal(value) buf.Write(b) } } func blake3Hex(b []byte) string { h := blake3.Sum256(b) return hex.EncodeToString(h[:]) }