diff --git a/README.md b/README.md index d3a3c7e..eb428a1 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,23 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil)) ``` +## Typed compliance events and the evidence report + +```go +decision := attesto.ModelDecision{Model: "credit-v1", Decision: "approve", ConfidenceBp: 8700} +payload, _ := decision.ToPayload() // regulation_refs attached, number-policy validated +client.LogEvent(ctx, streamID, attesto.EventInput{ + SourceRef: "d-1", EventType: decision.EventType(), Payload: payload, +}) +``` + +```bash +attesto report article12 --stream str_... --output report.md +``` + +The report is a deterministic template (never LLM-generated) stating what is +recorded and independently verifiable — it never asserts conformity. + ## Testing without Attesto: attestotest `go.attesto.eu/sdk/attestotest` starts a local httptest emulator with **real** diff --git a/attestotest/server.go b/attestotest/server.go index 846c310..d9ccf16 100644 --- a/attestotest/server.go +++ b/attestotest/server.go @@ -322,6 +322,7 @@ func (s *Server) append(stream attesto.M, body attesto.M) attesto.M { StreamEventID: streamEventID, TenantView: attesto.M{ "streamEventId": streamEventID, "seq_no": seqNo, + "event_type": envelope["event_type"], "event_hash": eventHash, "prev_event_hash": stream["lastEventHash"], "stream_head_hash": streamHeadHash, "payload_commitment": envelope["payload_commitment"], "mock": true, diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index cfab01f..f0cbdba 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -145,6 +145,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error { return a.marketplace(ctx, args[1:]) case "doctor": return a.doctor(ctx, args[1:]) + case "report": + return a.report(ctx, args[1:]) case "readiness": return a.readiness(args[1:]) default: diff --git a/cmd/attesto/report.go b/cmd/attesto/report.go new file mode 100644 index 0000000..870adf9 --- /dev/null +++ b/cmd/attesto/report.go @@ -0,0 +1,198 @@ +package main + +// [P2.2] `attesto report article12` — deterministic evidence-report templating +// (never LLM-generated). The report states what is recorded and independently +// verifiable; it never asserts conformity. + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + attesto "go.attesto.eu/sdk" +) + +const reportDisclaimer = "This report lists evidence recorded and independently " + + "verifiable on this stream. It does not assert conformity with any regulation: " + + "Attesto attests records; assessing legal obligations is for your advisors." + +var article12Elements = [][2]string{ + {"(a) period of each use", "event timestamps (occurred_at / ingested_at), hash-chained per stream"}, + {"(b) reference database checks", "input commitments on model_decision events"}, + {"(c) input data for the check", "payload commitments (canonical SHA-256, recomputable client-side)"}, + {"(d) identification of persons involved", "operator/actor references on decision and override events"}, +} + +func (a *app) report(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "article12" { + return errors.New("usage: attesto report article12 --stream [--from ts] [--to ts] [--output report.md]") + } + fs := flag.NewFlagSet("report article12", flag.ContinueOnError) + fs.SetOutput(a.err) + streamID := fs.String("stream", "", "stream id") + fromTs := fs.String("from", "", "RFC3339 window start") + toTs := fs.String("to", "", "RFC3339 window end") + output := fs.String("output", "", "write the markdown report to this file") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if *streamID == "" { + return errors.New("--stream is required") + } + client, err := a.bearerClient() + if err != nil { + return err + } + report, err := buildArticle12Report(ctx, client, *streamID, *fromTs, *toTs) + if err != nil { + return err + } + if *output != "" { + if err := os.WriteFile(*output, []byte(report), 0o644); err != nil { + return err + } + return a.write(map[string]any{"ok": true, "output": *output}) + } + _, err = fmt.Fprint(a.out, report) + return err +} + +func buildArticle12Report(ctx context.Context, client *attesto.Client, streamID, fromTs, toTs string) (string, error) { + var events []attesto.M + it := client.IterTenantStreamEvents(streamID, 200) + for { + event, err := it.Next(ctx) + if err != nil { + return "", err + } + if event == nil { + break + } + ts, _ := firstString(event, "occurred_at", "occurredAt", "ingested_at", "ingestedAt") + if ts != "" { + if fromTs != "" && ts < fromTs { + continue + } + if toTs != "" && ts > toTs { + continue + } + } + events = append(events, event) + } + + counts := map[string]int{} + var seqs []int + completenessInput := make([]map[string]any, 0, len(events)) + for _, event := range events { + eventType, _ := firstString(event, "event_type", "eventType") + if eventType == "" { + eventType = "(untyped)" + } + counts[eventType]++ + seq := int(asFloatValue(event["seq_no"], event["seqNo"])) + seqs = append(seqs, seq) + prev, _ := firstString(event, "prev_event_hash", "prevEventHash") + hash, _ := firstString(event, "event_hash", "eventHash") + completenessInput = append(completenessInput, map[string]any{ + "seq_no": seq, "prev_event_hash": prev, "event_hash": hash, + }) + } + sort.Ints(seqs) + completenessLine := "no events in range" + if len(seqs) > 0 { + comp := attesto.VerifyCompleteness(completenessInput, seqs[0], seqs[len(seqs)-1]) + if comp.OK { + completenessLine = fmt.Sprintf("PASS — sequence %d..%d is gap-free and hash-chained", seqs[0], seqs[len(seqs)-1]) + } else { + completenessLine = "FAIL — " + strings.Join(comp.Problems, ", ") + } + } + + var checkpointRows []string + cit := client.IterTenantCheckpoints(streamID, 200) + for { + checkpoint, err := cit.Next(ctx) + if err != nil { + break // endpoint optional on older deployments + } + if checkpoint == nil { + break + } + hash, _ := firstString(checkpoint, "checkpoint_hash", "checkpointHash", "rootHash") + tx, _ := firstString(checkpoint, "tx_hash", "txHash") + if tx == "" { + tx = "not yet anchored" + } + block := "—" + if b := asFloatValue(checkpoint["block_number"], checkpoint["blockNumber"]); b > 0 { + block = fmt.Sprintf("%d", int64(b)) + } + checkpointRows = append(checkpointRows, fmt.Sprintf("| `%s` | `%s` | %s |", hash, tx, block)) + } + if len(checkpointRows) == 0 { + checkpointRows = []string{"| (no checkpoints in range) | — | — |"} + } + + window := "stream start" + if fromTs != "" { + window = fromTs + } + windowEnd := "now" + if toTs != "" { + windowEnd = toTs + } + + var b strings.Builder + fmt.Fprintf(&b, "# Evidence report — stream `%s`\n\n%s\n\n", streamID, reportDisclaimer) + fmt.Fprintf(&b, "Window: %s → %s\n\n", window, windowEnd) + b.WriteString("## Logging coverage (EU AI Act Article 12(2))\n\n| Element | Evidence recorded |\n|---|---|\n") + for _, row := range article12Elements { + fmt.Fprintf(&b, "| %s | %s |\n", row[0], row[1]) + } + b.WriteString("\n## Events in range\n\n| Event type | Count |\n|---|---|\n") + types := make([]string, 0, len(counts)) + for t := range counts { + types = append(types, t) + } + sort.Strings(types) + for _, t := range types { + fmt.Fprintf(&b, "| `%s` | %d |\n", t, counts[t]) + } + fmt.Fprintf(&b, "| **total** | **%d** |\n\n", len(events)) + fmt.Fprintf(&b, "**Completeness (no omissions):** %s\n\n", completenessLine) + b.WriteString("## Verification path per checkpoint\n\n| Checkpoint | Anchor tx | Block |\n|---|---|---|\n") + b.WriteString(strings.Join(checkpointRows, "\n")) + b.WriteString("\n\n## Replay these checks yourself\n\n```bash\n") + b.WriteString("go get go.attesto.eu/sdk # or: pip install attesto / npm i @attesto/sdk\n") + fmt.Fprintf(&b, "# offline, no Attesto call: VerifyReceiptOffline / VerifyInclusionProof /\n# VerifyCompleteness over stream %s\n", streamID) + b.WriteString("attesto verify truth-package --file \n```\n\n") + fmt.Fprintf(&b, "_Generated by attesto-go/%s (deterministic template; no AI involved)._\n", attesto.SDKVersion) + return b.String(), nil +} + +func firstString(m map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + if s, ok := m[key].(string); ok && s != "" { + return s, true + } + } + return "", false +} + +func asFloatValue(values ...any) float64 { + for _, v := range values { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case int64: + return float64(n) + } + } + return 0 +} diff --git a/cmd/attesto/report_test.go b/cmd/attesto/report_test.go new file mode 100644 index 0000000..780b2ae --- /dev/null +++ b/cmd/attesto/report_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "strings" + "testing" + + attesto "go.attesto.eu/sdk" + "go.attesto.eu/sdk/attestotest" +) + +func TestArticle12ReportWordingAndStructure(t *testing.T) { + server := attestotest.NewServer() + defer server.Close() + client, err := attesto.NewBearerClient("dummy-tenant-bearer-token", attesto.WithBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"}) + if err != nil { + t.Fatal(err) + } + decision := attesto.ModelDecision{Model: "m", Decision: "approve", ConfidenceBp: 8700} + payload, err := decision.ToPayload() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{ + SourceRef: "e", EventType: decision.EventType(), Payload: payload, + }); err != nil { + t.Fatal(err) + } + } + + report, err := buildArticle12Report(ctx, client, stream.StreamID, "", "") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Article 12(2)", + "| `attesto.model_decision` | 2 |", + "PASS — sequence 1..2 is gap-free and hash-chained", + "deterministic template; no AI involved", + "attesto verify truth-package", + } { + if !strings.Contains(report, want) { + t.Errorf("report missing %q", want) + } + } + lower := strings.ToLower(report) + if strings.Contains(lower, "compliant") || strings.Contains(lower, "compliance guaranteed") { + t.Error("claims discipline violated: report must never say compliant") + } +} diff --git a/events.go b/events.go new file mode 100644 index 0000000..1f2fb71 --- /dev/null +++ b/events.go @@ -0,0 +1,135 @@ +package attesto + +// [P2.2] Typed compliance events — SDK-side conventions, no backend change. +// Each ToPayload() returns a plain payload map with regulation_refs included, +// validated against the committed-payload number policy. Recording these never +// claims conformity: Attesto attests records. + +// Typed event type identifiers. +const ( + EventTypeModelDecision = "attesto.model_decision" + EventTypeHumanOverride = "attesto.human_override" + EventTypeIncidentReport = "attesto.incident_report" + EventTypeDataAccess = "attesto.data_access" +) + +// Regulation references per typed event (conventions for reports/auditors). +var ( + RefsModelDecision = []string{"EU-AI-Act:Art.12", "EU-AI-Act:Art.14"} + RefsHumanOverride = []string{"EU-AI-Act:Art.14"} + RefsIncidentReport = []string{"NIS2:Art.23", "EU-AI-Act:Art.62"} + RefsDataAccess = []string{"GDPR:Art.30", "GDPR:Art.6"} +) + +func finishPayload(payload M, refs []string, extra M) (M, error) { + for key, value := range extra { + payload[key] = value + } + out := M{} + for key, value := range payload { + if value != nil && value != "" { + out[key] = value + } + } + out["regulation_refs"] = refs + if err := AssertCommitmentSafeNumbers(map[string]any(out), "$"); err != nil { + return nil, err + } + return out, nil +} + +// ModelDecision is one model-driven decision (commitments only; raw inputs and +// outputs never leave your process). +type ModelDecision struct { + Model string + InputCommitment map[string]string + OutputCommitment map[string]string + Decision string + ConfidenceBp int // basis points keep integers commitment-safe + HumanInLoop bool + OperatorRef string + Extra M +} + +func (e ModelDecision) EventType() string { return EventTypeModelDecision } + +func (e ModelDecision) ToPayload() (M, error) { + return finishPayload(M{ + "model": e.Model, + "input_commitment": orNil(e.InputCommitment), + "output_commitment": orNil(e.OutputCommitment), + "decision": e.Decision, + "confidence_bp": e.ConfidenceBp, + "human_in_loop": e.HumanInLoop, + "operator_ref": e.OperatorRef, + }, RefsModelDecision, e.Extra) +} + +// HumanOverride records a human overriding a model decision (Art. 14). +type HumanOverride struct { + OriginalEventRef string + OperatorRef string + JustificationCommitment map[string]string + NewDecision string + Extra M +} + +func (e HumanOverride) EventType() string { return EventTypeHumanOverride } + +func (e HumanOverride) ToPayload() (M, error) { + return finishPayload(M{ + "original_event_ref": e.OriginalEventRef, + "operator_ref": e.OperatorRef, + "justification_commitment": orNil(e.JustificationCommitment), + "new_decision": e.NewDecision, + }, RefsHumanOverride, e.Extra) +} + +// IncidentReport is a reportable incident with NIS2-style field names. +type IncidentReport struct { + Severity string + Category string + DetectedAt string + SummaryCommitment map[string]string + AffectedService string + Extra M +} + +func (e IncidentReport) EventType() string { return EventTypeIncidentReport } + +func (e IncidentReport) ToPayload() (M, error) { + return finishPayload(M{ + "severity": e.Severity, + "category": e.Category, + "detected_at": e.DetectedAt, + "summary_commitment": orNil(e.SummaryCommitment), + "affected_service": e.AffectedService, + }, RefsIncidentReport, e.Extra) +} + +// DataAccess records an access to personal or regulated data. +type DataAccess struct { + SubjectRefCommitment map[string]string + Purpose string + LegalBasis string + AccessorRef string + Extra M +} + +func (e DataAccess) EventType() string { return EventTypeDataAccess } + +func (e DataAccess) ToPayload() (M, error) { + return finishPayload(M{ + "subject_ref_commitment": orNil(e.SubjectRefCommitment), + "purpose": e.Purpose, + "legal_basis": e.LegalBasis, + "accessor_ref": e.AccessorRef, + }, RefsDataAccess, e.Extra) +} + +func orNil(m map[string]string) any { + if len(m) == 0 { + return nil + } + return m +}