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:
17
README.md
17
README.md
@@ -129,6 +129,23 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead
|
|||||||
client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil))
|
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
|
## Testing without Attesto: attestotest
|
||||||
|
|
||||||
`go.attesto.eu/sdk/attestotest` starts a local httptest emulator with **real**
|
`go.attesto.eu/sdk/attestotest` starts a local httptest emulator with **real**
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ func (s *Server) append(stream attesto.M, body attesto.M) attesto.M {
|
|||||||
StreamEventID: streamEventID,
|
StreamEventID: streamEventID,
|
||||||
TenantView: attesto.M{
|
TenantView: attesto.M{
|
||||||
"streamEventId": streamEventID, "seq_no": seqNo,
|
"streamEventId": streamEventID, "seq_no": seqNo,
|
||||||
|
"event_type": envelope["event_type"],
|
||||||
"event_hash": eventHash, "prev_event_hash": stream["lastEventHash"],
|
"event_hash": eventHash, "prev_event_hash": stream["lastEventHash"],
|
||||||
"stream_head_hash": streamHeadHash,
|
"stream_head_hash": streamHeadHash,
|
||||||
"payload_commitment": envelope["payload_commitment"], "mock": true,
|
"payload_commitment": envelope["payload_commitment"], "mock": true,
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
|
|||||||
return a.marketplace(ctx, args[1:])
|
return a.marketplace(ctx, args[1:])
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return a.doctor(ctx, args[1:])
|
return a.doctor(ctx, args[1:])
|
||||||
|
case "report":
|
||||||
|
return a.report(ctx, args[1:])
|
||||||
case "readiness":
|
case "readiness":
|
||||||
return a.readiness(args[1:])
|
return a.readiness(args[1:])
|
||||||
default:
|
default:
|
||||||
|
|||||||
198
cmd/attesto/report.go
Normal file
198
cmd/attesto/report.go
Normal 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
|
||||||
|
}
|
||||||
56
cmd/attesto/report_test.go
Normal file
56
cmd/attesto/report_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
135
events.go
Normal file
135
events.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user