sdk(P2.2): typed compliance events + attest/session + Article 12 report

Typed events as SDK-side conventions (no backend change): ModelDecision /
HumanOverride / IncidentReport (NIS2 field names) / DataAccess as Python
dataclasses, TypeScript builders, and Go structs — each serializing to a plain
payload with regulation_refs (EU AI Act Art.12/14, NIS2 Art.23, AI-Act Art.62,
GDPR Art.30/6) and self-validating against the committed-payload number policy.

Python ergonomics: @attest(client, stream_id=...) wraps any function — one
event per call with commitments over args/kwargs and result (raw values never
leave the process), .last_receipt on the wrapper, exceptions log an
IncidentReport-shaped event (commitment over the traceback) and re-raise;
logging failures never break the workload (log-and-continue; strict=True is
the only raising mode — all test-enforced). session(...) groups typed events
under shared session_id/actor_ref metadata.

Evidence report: attesto.reports.article12(...) in Python and
`attesto report article12 --stream ... --output report.md` in the Go CLI —
deterministic templating (never LLM-generated) built only from existing tenant
endpoints: Art.12(2) coverage table, per-type event counts, P1.3 completeness
verdict, checkpoint -> anchor-tx -> block path, and replayable verification
commands. Claims discipline test-enforced in both languages: the words
"compliant"/"compliance guaranteed" never appear — the report states evidence
recorded and independently verifiable. The mock emulators now expose
event_type in tenant listings so report tests run end-to-end against P2.3.

Sweep green: Python 94, TS 59, Go all packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-11 23:23:13 +02:00
parent 227ea57bd5
commit ce9b8ccfbb
6 changed files with 409 additions and 0 deletions

View File

@@ -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:

198
cmd/attesto/report.go Normal file
View File

@@ -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 <id> [--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 <export.zip>\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
}

View File

@@ -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")
}
}