diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index dc47cfe..6b291ef 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v6
- name: Log in to GHCR
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -23,7 +23,7 @@ jobs:
- name: Extract metadata
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -32,7 +32,7 @@ jobs:
type=sha
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
file: deploy/Dockerfile
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index b19e52f..d256ff4 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v6
- name: Setup Pages
- uses: actions/configure-pages@v5
+ uses: actions/configure-pages@v6
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
@@ -43,4 +43,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index 0bb07a4..1742161 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
+# Local AI assistant context (not committed)
+CLAUDE.md
+.claude/
+
# Binaries
*.exe
*.exe~
@@ -15,10 +19,10 @@ __debug_bin*
# Code coverage profiles and other test artifacts
*.out
-coverage.*
+/coverage.out
+/coverage.html
*.coverprofile
profile.cov
-coverage.html
# Dependency directories
vendor/
@@ -65,3 +69,19 @@ Thumbs.db
*.log
*.pid
tmp/
+
+# AI agent local instructions and tooling artifacts
+AGENTS.md
+CLAUDE.md
+GEMINI.md
+COPILOT.md
+.claude/
+.codex/
+.cursor/
+.aider/
+.roo/
+.windsurf/
+.continue/
+.cline/
+.mcp/
+.github/copilot-instructions.md
diff --git a/.golangci.yml b/.golangci.yml
index aaf4015..84d9ed8 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -10,7 +10,8 @@ formatters:
- goimports
settings:
goimports:
- local-prefixes: github.com/scalytics/kafgraph
+ local-prefixes:
+ - github.com/scalytics/kafgraph
linters:
enable:
@@ -29,8 +30,6 @@ linters:
- errcheck
- gosec
settings:
- govet:
- shadow: true
revive:
rules:
- name: exported
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index b325e6b..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,168 +0,0 @@
-# KafGraph
-
-KafGraph is the distributed shared brain of collaborating agents — a graph database
-and reflection engine written in Go. It ingests agent conversation data from Apache
-Kafka topics, structures it as a property graph, and provides temporal reflection
-services that allow agents and teams to learn from past interactions.
-
-## Build Commands
-
-```bash
-make build # Build the kafgraph binary
-make build-linux # Cross-compile for linux/amd64
-make install # Install to $GOPATH/bin
-make clean # Remove build artifacts
-```
-
-## Test Commands
-
-```bash
-make test # Run all unit + E2E tests
-make test-unit # Unit tests only
-make test-e2e # End-to-end tests (in-process, uses temp BadgerDB)
-make test-integration # Integration tests (requires docker-compose)
-make test-fuzz # Fuzz tests (Go native fuzzing)
-make test-race # Unit + E2E with -race detector
-```
-
-## Lint & Format
-
-```bash
-make lint # Run golangci-lint
-make lint-fix # Auto-fix lint issues
-make vet # go vet
-make fmt # gofmt -w
-make fmt-check # Check formatting (CI gate)
-```
-
-## Coverage
-
-```bash
-make cover # Generate coverage report
-make cover-html # Open HTML coverage report
-make cover-check # Fail if coverage < 80%
-```
-
-## Docker
-
-```bash
-make docker-build # Build multi-stage Docker image
-make docker-push # Push to registry
-make docker-up # Start dev environment (MinIO + Kafka + KafGraph)
-make docker-down # Stop dev environment
-make docker-logs # Tail dev environment logs
-```
-
-## Docs
-
-```bash
-make docs-serve # Serve Jekyll docs locally (http://localhost:4000)
-make docs-build # Build static Jekyll site
-make docs-sync-check # Verify docs match code (CI gate)
-```
-
-## Requirements & Specs
-
-```bash
-make req-index # Regenerate SPEC/FR/INDEX.md
-make spec-check # Validate requirement files
-```
-
-## Release & Quality Gates
-
-```bash
-make commit-check # Pre-push gate: fmt + vet + race + coverage
-make release-check # Full pre-release gate: lint + test + cover + docs + fmt
-make ci # Simulate CI pipeline locally
-```
-
-## Code Maintenance Procedures
-
-**Never commit directly to main after the initial scaffold.** Configure GitHub branch
-protection rules: require PR with 0 approvals minimum, merge only after all CI checks
-pass. A single broken commit can block the workflow for hours — clean code in main is
-worth the discipline.
-
-### Pre-push checklist (`make commit-check`)
-
-Every push must pass: `go fmt`, `go vet`, `go test -race`, 80% coverage minimum, and
-`govulncheck`. Run `make commit-check` before every push. The pre-commit hook
-(`hack/pre-commit.sh`) enforces formatting and linting automatically.
-
-### CI pipeline (runs on every PR)
-
-1. `golangci-lint` — static analysis with strict config
-2. `go vet` — correctness checks
-3. `go test -race` — race condition detection on all tests
-4. Coverage gate — 80% minimum, 100% on critical paths (storage, graph, server)
-5. `gofmt` check — consistent formatting
-6. License header check — Apache 2.0 on all `.go` files
-7. `gosec` — security-focused static analysis (blocks merge on findings)
-8. `govulncheck` — known vulnerability scanning in dependencies
-9. Docs sync check — documentation matches code
-
-### Security scanning (free, no GitHub Advanced Security needed)
-
-Instead of CodeQL ($30/month), we use two free tools that cover the same ground:
-- **gosec** — Go-specific security linter (SQL injection, command injection, hardcoded
- credentials, weak crypto, etc.). Runs via golangci-lint on every PR.
-- **govulncheck** — Go's official vulnerability checker. Scans dependencies against the
- Go vulnerability database. Runs on every PR and locally via `make vulncheck`.
-
-Both block merge on findings. Run `make sec` locally to check both.
-
-### Branch protection rules (configure in GitHub Settings > Rules)
-
-- Require pull request before merging (0 reviews minimum)
-- Require status checks to pass: `ci`, `security`
-- No direct pushes to main after initial setup
-
-## Conventions
-
-- **Go version**: 1.25+ with `CGO_ENABLED=0` (static linking)
-- **Linter**: golangci-lint with config from `.golangci.yml`
-- **Coverage**: 80% minimum enforced by `hack/coverage.sh`; 100% on critical packages
-- **Race detection**: all tests run with `-race` in CI
-- **Security**: gosec + govulncheck on all PRs (free, blocks merge on findings)
-- **License**: Apache 2.0 header on all `.go` files (enforced by `hack/license-header.sh`)
-- **Sync gate**: every public API change must update `docs/` and tests (release gate)
-- **Requirements**: tracked as `SPEC/FR/req-NNN.md` with monotonic numbering
-- **Phases**: tracked in `SPEC/PLAN.md`
-- **Commit messages**: imperative mood, reference `req-NNN` where applicable
-- **No direct commits to main**: always use PRs after initial scaffold
-
-## Architecture
-
-- **SPEC/initial-idea.md** — project vision and motivation
-- **SPEC/requirements.md** — full functional, non-functional, and integration requirements
-- **SPEC/solution-design.md** — technology selection, layer architecture, data model
-- **SPEC/about-agent-brains.md** — agent brain concept and design rationale
-- **SPEC/kafclaw-topic-reference.md** — KafClaw topic hierarchy and wire format
-- **SPEC/PLAN.md** — phase tracker (0: Foundation → 8: Hardening)
-
-## Key Packages
-
-| Package | Purpose |
-|---------|---------|
-| `cmd/kafgraph` | Entry point, CLI setup |
-| `internal/config` | Viper-based configuration loader |
-| `internal/graph` | Core graph API (CRUD nodes/edges, property graph model) |
-| `internal/storage` | Storage engines (BadgerDB default) |
-| `internal/reflect` | Reflection Engine (scheduler, cycle runner, scorer, feedback checker) |
-| `internal/server` | Bolt v4 protocol, HTTP API, Brain Tool API |
-
-## Reference Repos
-
-- **KafScale** (`github.com/scalytics/platform`) — Go infrastructure patterns, Makefile, Docker, CI/CD
-- **KafClaw** (`github.com/kamir/KafClaw`) — Agent skills system, SKILL.md manifests, Jekyll docs
-
-## Skills
-
-Each brain tool is defined as a skill in `skills/brain_*/SKILL.md`:
-- `brain_search` — Semantic search across the knowledge graph
-- `brain_recall` — Load accumulated agent context
-- `brain_capture` — Write insights/decisions into the brain
-- `brain_recent` — Browse recent activity
-- `brain_patterns` — Surface recurring themes and connections
-- `brain_reflect` — Trigger on-demand reflection cycle
-- `brain_feedback` — Submit human feedback on reflection cycles
diff --git a/Makefile b/Makefile
index a1eb98d..f7c940f 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,7 @@ LDFLAGS := -s -w \
BIN_DIR := bin
STAMP_DIR := .build
+TOOLS_BIN_DIR := $(STAMP_DIR)/bin
DOCKER_REGISTRY ?= ghcr.io/scalytics
DOCKER_IMAGE ?= $(DOCKER_REGISTRY)/kafgraph
@@ -36,6 +37,8 @@ COVERAGE_FILE := coverage.out
GOLANGCI_LINT ?= golangci-lint
GOLANGCI_FLAGS ?= --config .golangci.yml
+GOVULNCHECK_VERSION ?= latest
+GOVULNCHECK ?= $(abspath $(TOOLS_BIN_DIR))/govulncheck
JEKYLL ?= bundle exec jekyll
DOCS_DIR := docs
@@ -139,14 +142,18 @@ fmt-check: ## Check formatting (CI gate)
# ─── Security ────────────────────────────────────────────────────────────────
+$(GOVULNCHECK):
+ @mkdir -p $(TOOLS_BIN_DIR)
+ GOBIN=$(abspath $(TOOLS_BIN_DIR)) $(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
+
.PHONY: sec
-sec: ## Run security checks (gosec via lint + govulncheck)
+sec: $(GOVULNCHECK) ## Run security checks (gosec via lint + govulncheck)
$(GOLANGCI_LINT) run $(GOLANGCI_FLAGS) --enable-only gosec ./...
- govulncheck ./...
+ $(GOVULNCHECK) ./...
.PHONY: vulncheck
-vulncheck: ## Check for known vulnerabilities in dependencies
- govulncheck ./...
+vulncheck: $(GOVULNCHECK) ## Check for known vulnerabilities in dependencies
+ $(GOVULNCHECK) ./...
# ─── Docker ──────────────────────────────────────────────────────────────────
diff --git a/README.md b/README.md
index fd21b07..2bd1bb5 100644
--- a/README.md
+++ b/README.md
@@ -14,23 +14,57 @@ Apache 2.0. Open beta.
---
## What KafGraph is
-
+
KafGraph is the memory layer for AI agent teams. It ingests every conversation,
decision, and artifact that flows through your agent group, structures it as a
queryable property graph, and exposes it back to agents through tool calls. No
agent ever starts from zero.
-
-```
-Agent conversations → Kafka topics → KafScale processor → KafGraph (BadgerDB)
- │
- ┌────────────────────┤
- │ │
- Brain Tool API Cypher / Bolt v4
- (agent access) (tooling access)
+
+```mermaid
+%%{init: {'theme':'neutral', 'themeVariables': {
+ 'primaryColor':'transparent',
+ 'primaryBorderColor':'#1D9E75',
+ 'primaryTextColor':'#1D9E75',
+ 'lineColor':'#1D9E75',
+ 'clusterBkg':'transparent',
+ 'clusterBorder':'#1D9E75',
+ 'edgeLabelBackground':'transparent'
+}}}%%
+flowchart LR
+ subgraph Agents["AI agent team"]
+ A1[agent.researcher]
+ A2[agent.coder]
+ A3[agent.reviewer]
+ A4[agent.lead]
+ end
+
+ subgraph Transport["Transport"]
+ K[Kafka topics
group.<name>.*]
+ S[(S3 segments
MinIO)]
+ end
+
+ subgraph KG["KafGraph"]
+ P[KafScale processor
5-layer pipeline]
+ G[(Property graph
BadgerDB)]
+ BT[Brain Tool API
7 tools]
+ Bolt[Bolt v4 / OpenCypher]
+ end
+
+ A1 & A2 & A3 & A4 -- conversations,
decisions,
shared memory --> K
+ K --> S
+ S -- direct read,
no broker hop --> P
+ P --> G
+ G --> BT
+ G --> Bolt
+ BT -- brain_search
brain_recall
brain_capture
brain_reflect --> A1 & A2 & A3 & A4
+ Bolt -- inspection,
dashboards --> External((Bolt clients))
+
+ classDef default fill:transparent,stroke:#1D9E75,color:#1D9E75
+ class A1,A2,A3,A4,P,G,BT,Bolt,K,S,External default
```
-
+
Two deployment modes from one binary:
-
+
* **Embedded.** A local brain for one agent. BadgerDB on disk, Brain Tool API on
localhost. Useful for per-agent personalization, coding agents on a developer
laptop, or air-gapped single-agent workloads.
@@ -38,6 +72,7 @@ Two deployment modes from one binary:
membership via `hashicorp/memberlist`, agentID partitioning via FNV-1a,
cross-partition fan-out RPC. The mode no other agent memory system ships.
+
## Why "shared brain"
Every other agent memory system on the market (Mem0, Zep / Graphiti, Cognee,
diff --git a/go.mod b/go.mod
index 1dca63d..f8946b9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,36 +1,38 @@
module github.com/scalytics/kafgraph
-go 1.25
+go 1.25.0
require (
- github.com/blevesearch/bleve/v2 v2.5.7
+ github.com/blevesearch/bleve/v2 v2.6.0
github.com/dgraph-io/badger/v4 v4.9.1
github.com/hashicorp/memberlist v0.5.4
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
+ gopkg.in/yaml.v3 v3.0.1
)
require (
- github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
+ github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
- github.com/bits-and-blooms/bitset v1.22.0 // indirect
- github.com/blevesearch/bleve_index_api v1.2.11 // indirect
- github.com/blevesearch/geo v0.2.4 // indirect
- github.com/blevesearch/go-faiss v1.0.26 // indirect
+ github.com/bits-and-blooms/bitset v1.24.2 // indirect
+ github.com/blevesearch/bleve_index_api v1.3.11 // indirect
+ github.com/blevesearch/geo v0.2.5 // indirect
+ github.com/blevesearch/go-faiss v1.1.0 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
- github.com/blevesearch/mmap-go v1.0.4 // indirect
- github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
+ github.com/blevesearch/mmap-go v1.2.0 // indirect
+ github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
- github.com/blevesearch/vellum v1.1.0 // indirect
- github.com/blevesearch/zapx/v11 v11.4.2 // indirect
- github.com/blevesearch/zapx/v12 v12.4.2 // indirect
- github.com/blevesearch/zapx/v13 v13.4.2 // indirect
- github.com/blevesearch/zapx/v14 v14.4.2 // indirect
- github.com/blevesearch/zapx/v15 v15.4.2 // indirect
- github.com/blevesearch/zapx/v16 v16.2.8 // indirect
+ github.com/blevesearch/vellum v1.2.0 // indirect
+ github.com/blevesearch/zapx/v11 v11.4.3 // indirect
+ github.com/blevesearch/zapx/v12 v12.4.3 // indirect
+ github.com/blevesearch/zapx/v13 v13.4.3 // indirect
+ github.com/blevesearch/zapx/v14 v14.4.3 // indirect
+ github.com/blevesearch/zapx/v15 v15.4.3 // indirect
+ github.com/blevesearch/zapx/v16 v16.3.4 // indirect
+ github.com/blevesearch/zapx/v17 v17.1.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
@@ -39,7 +41,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
- github.com/golang/snappy v0.0.4 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -65,17 +67,16 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/otel v1.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/mod v0.29.0 // indirect
- golang.org/x/net v0.47.0 // indirect
- golang.org/x/sync v0.18.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.31.0 // indirect
- golang.org/x/tools v0.38.0 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index b5f7554..884ec0d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
-github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
-github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
+github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw=
+github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -12,45 +12,46 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
-github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
-github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
-github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
-github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
-github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
-github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
-github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
-github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
-github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
-github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
+github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
+github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
+github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4=
+github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY=
+github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
+github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
+github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
+github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
+github.com/blevesearch/go-faiss v1.1.0 h1:xM7Jc0ZUCv5lssG9Ohj3Jv0SdTpxcUABU1dDt9XVsc4=
+github.com/blevesearch/go-faiss v1.1.0/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
-github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
-github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
-github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
-github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
+github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
+github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
+github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
+github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
-github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
-github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
-github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
-github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
-github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
-github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
-github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
-github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
-github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
-github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
-github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
-github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
-github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
-github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
+github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
+github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
+github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
+github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
+github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
+github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
+github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
+github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
+github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
+github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
+github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
+github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
+github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
+github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
+github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk=
+github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -96,8 +97,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
-github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
@@ -194,8 +195,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
@@ -218,7 +219,6 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -226,36 +226,36 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -268,16 +268,15 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -299,7 +298,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/hack/coverage.sh b/hack/coverage.sh
new file mode 100755
index 0000000..db5f1cd
--- /dev/null
+++ b/hack/coverage.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Copyright 2026 Scalytics, Inc.
+# Copyright 2026 Mirko Kämpf
+#
+# Fail if total Go coverage drops below the configured threshold.
+# Usage: hack/coverage.sh
+
+set -euo pipefail
+
+if [ "$#" -ne 2 ]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+COVERAGE_FILE="$1"
+MIN_COVERAGE="$2"
+
+if [ ! -f "$COVERAGE_FILE" ]; then
+ echo "FAIL: coverage file not found: $COVERAGE_FILE" >&2
+ exit 1
+fi
+
+TOTAL_COVERAGE="$(
+ go tool cover -func="$COVERAGE_FILE" \
+ | awk '/^total:/ { sub(/%/, "", $3); print $3 }'
+)"
+
+if [ -z "$TOTAL_COVERAGE" ]; then
+ echo "FAIL: could not read total coverage from $COVERAGE_FILE" >&2
+ exit 1
+fi
+
+if awk -v total="$TOTAL_COVERAGE" -v min="$MIN_COVERAGE" 'BEGIN { exit !(total + 0 >= min + 0) }'; then
+ echo "PASS: coverage ${TOTAL_COVERAGE}% >= ${MIN_COVERAGE}%"
+ exit 0
+fi
+
+echo "FAIL: coverage ${TOTAL_COVERAGE}% < ${MIN_COVERAGE}%"
+exit 1
diff --git a/internal/compliance/dataflow.go b/internal/compliance/dataflow.go
index 2fdae82..1316134 100644
--- a/internal/compliance/dataflow.go
+++ b/internal/compliance/dataflow.go
@@ -173,9 +173,10 @@ type DataFlowValidationResult struct {
func (r *DataFlowValidationResult) SummaryText() string {
pass, fail := 0, 0
for _, c := range r.Checks {
- if c.Status == EvalPass {
+ switch c.Status {
+ case EvalPass:
pass++
- } else if c.Status == EvalFail {
+ case EvalFail:
fail++
}
}
@@ -223,12 +224,12 @@ func hasEdgeLabel(edges EdgeList, label string) bool {
type gdprFlow001 struct{}
-func (r *gdprFlow001) ID() string { return "GDPR-FLOW-001" }
+func (r *gdprFlow001) ID() string { return "GDPR-FLOW-001" }
func (r *gdprFlow001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprFlow001) Module() string { return "dataflow" }
-func (r *gdprFlow001) Article() string { return "Art. 30" }
-func (r *gdprFlow001) Title() string { return "Data categories must be documented per data flow" }
-func (r *gdprFlow001) Severity() Severity { return SeverityHigh }
+func (r *gdprFlow001) Module() string { return "dataflow" }
+func (r *gdprFlow001) Article() string { return "Art. 30" }
+func (r *gdprFlow001) Title() string { return "Data categories must be documented per data flow" }
+func (r *gdprFlow001) Severity() Severity { return SeverityHigh }
func (r *gdprFlow001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkHasEdge(g, r.ID(), r.Severity(), "DataFlow", "CARRIES")
@@ -238,12 +239,12 @@ func (r *gdprFlow001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprFlow002 struct{}
-func (r *gdprFlow002) ID() string { return "GDPR-FLOW-002" }
+func (r *gdprFlow002) ID() string { return "GDPR-FLOW-002" }
func (r *gdprFlow002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprFlow002) Module() string { return "dataflow" }
-func (r *gdprFlow002) Article() string { return "Art. 6" }
-func (r *gdprFlow002) Title() string { return "Legal basis required for every data flow" }
-func (r *gdprFlow002) Severity() Severity { return SeverityCritical }
+func (r *gdprFlow002) Module() string { return "dataflow" }
+func (r *gdprFlow002) Article() string { return "Art. 6" }
+func (r *gdprFlow002) Title() string { return "Legal basis required for every data flow" }
+func (r *gdprFlow002) Severity() Severity { return SeverityCritical }
func (r *gdprFlow002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkHasEdge(g, r.ID(), r.Severity(), "DataFlow", "GOVERNED_BY")
@@ -253,12 +254,12 @@ func (r *gdprFlow002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprFlow003 struct{}
-func (r *gdprFlow003) ID() string { return "GDPR-FLOW-003" }
+func (r *gdprFlow003) ID() string { return "GDPR-FLOW-003" }
func (r *gdprFlow003) Framework() Framework { return FrameworkGDPR }
-func (r *gdprFlow003) Module() string { return "dataflow" }
-func (r *gdprFlow003) Article() string { return "Art. 44-49" }
-func (r *gdprFlow003) Title() string { return "International transfers require adequate safeguards" }
-func (r *gdprFlow003) Severity() Severity { return SeverityCritical }
+func (r *gdprFlow003) Module() string { return "dataflow" }
+func (r *gdprFlow003) Article() string { return "Art. 44-49" }
+func (r *gdprFlow003) Title() string { return "International transfers require adequate safeguards" }
+func (r *gdprFlow003) Severity() Severity { return SeverityCritical }
func (r *gdprFlow003) Evaluate(g GraphQuerier) ([]RuleResult, error) {
flows, err := g.NodesByLabel("DataFlow")
@@ -285,12 +286,12 @@ func (r *gdprFlow003) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprFlow004 struct{}
-func (r *gdprFlow004) ID() string { return "GDPR-FLOW-004" }
+func (r *gdprFlow004) ID() string { return "GDPR-FLOW-004" }
func (r *gdprFlow004) Framework() Framework { return FrameworkGDPR }
-func (r *gdprFlow004) Module() string { return "dataflow" }
-func (r *gdprFlow004) Article() string { return "Art. 9" }
-func (r *gdprFlow004) Title() string { return "Special category data flows require explicit consent" }
-func (r *gdprFlow004) Severity() Severity { return SeverityCritical }
+func (r *gdprFlow004) Module() string { return "dataflow" }
+func (r *gdprFlow004) Article() string { return "Art. 9" }
+func (r *gdprFlow004) Title() string { return "Special category data flows require explicit consent" }
+func (r *gdprFlow004) Severity() Severity { return SeverityCritical }
func (r *gdprFlow004) Evaluate(g GraphQuerier) ([]RuleResult, error) {
flows, err := g.NodesByLabel("DataFlow")
@@ -325,11 +326,13 @@ func (r *gdprFlow004) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprFlow005 struct{}
-func (r *gdprFlow005) ID() string { return "GDPR-FLOW-005" }
+func (r *gdprFlow005) ID() string { return "GDPR-FLOW-005" }
func (r *gdprFlow005) Framework() Framework { return FrameworkGDPR }
-func (r *gdprFlow005) Module() string { return "dataflow" }
-func (r *gdprFlow005) Article() string { return "Art. 30" }
-func (r *gdprFlow005) Title() string { return "Active processing activities should have data flows defined" }
+func (r *gdprFlow005) Module() string { return "dataflow" }
+func (r *gdprFlow005) Article() string { return "Art. 30" }
+func (r *gdprFlow005) Title() string {
+ return "Active processing activities should have data flows defined"
+}
func (r *gdprFlow005) Severity() Severity { return SeverityMedium }
func (r *gdprFlow005) Evaluate(g GraphQuerier) ([]RuleResult, error) {
@@ -371,12 +374,12 @@ func (r *gdprFlow005) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprInsp001 struct{}
-func (r *gdprInsp001) ID() string { return "GDPR-INSP-001" }
+func (r *gdprInsp001) ID() string { return "GDPR-INSP-001" }
func (r *gdprInsp001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprInsp001) Module() string { return "inspection" }
-func (r *gdprInsp001) Article() string { return "Art. 5(2)" }
-func (r *gdprInsp001) Title() string { return "No inspection findings overdue" }
-func (r *gdprInsp001) Severity() Severity { return SeverityHigh }
+func (r *gdprInsp001) Module() string { return "inspection" }
+func (r *gdprInsp001) Article() string { return "Art. 5(2)" }
+func (r *gdprInsp001) Title() string { return "No inspection findings overdue" }
+func (r *gdprInsp001) Severity() Severity { return SeverityHigh }
func (r *gdprInsp001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
findings, err := g.NodesByLabel("InspectionFinding")
@@ -415,12 +418,12 @@ func (r *gdprInsp001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprInsp002 struct{}
-func (r *gdprInsp002) ID() string { return "GDPR-INSP-002" }
+func (r *gdprInsp002) ID() string { return "GDPR-INSP-002" }
func (r *gdprInsp002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprInsp002) Module() string { return "inspection" }
-func (r *gdprInsp002) Article() string { return "Art. 5(2)" }
-func (r *gdprInsp002) Title() string { return "Completed remediations must be verified" }
-func (r *gdprInsp002) Severity() Severity { return SeverityMedium }
+func (r *gdprInsp002) Module() string { return "inspection" }
+func (r *gdprInsp002) Article() string { return "Art. 5(2)" }
+func (r *gdprInsp002) Title() string { return "Completed remediations must be verified" }
+func (r *gdprInsp002) Severity() Severity { return SeverityMedium }
func (r *gdprInsp002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
actions, err := g.NodesByLabel("RemediationAction")
diff --git a/internal/compliance/engine.go b/internal/compliance/engine.go
index 916de50..d78dbd4 100644
--- a/internal/compliance/engine.go
+++ b/internal/compliance/engine.go
@@ -156,15 +156,15 @@ func (e *Engine) RunScan(ctx context.Context, req ScanRequest) (*ScanResult, err
func (e *Engine) storeScanResults(result *ScanResult) error {
// Create ComplianceScan node.
scanNode, err := e.graph.CreateNode("ComplianceScan", graph.Properties{
- "scanId": result.ScanID,
- "framework": string(result.Framework),
- "triggeredBy": result.TriggeredBy,
- "startedAt": result.StartedAt.Format(time.RFC3339),
- "completedAt": result.CompletedAt.Format(time.RFC3339),
- "passCount": result.PassCount,
- "failCount": result.FailCount,
+ "scanId": result.ScanID,
+ "framework": string(result.Framework),
+ "triggeredBy": result.TriggeredBy,
+ "startedAt": result.StartedAt.Format(time.RFC3339),
+ "completedAt": result.CompletedAt.Format(time.RFC3339),
+ "passCount": result.PassCount,
+ "failCount": result.FailCount,
"warningCount": result.WarningCount,
- "score": result.Score,
+ "score": result.Score,
})
if err != nil {
return fmt.Errorf("create scan node: %w", err)
diff --git a/internal/compliance/engine_test.go b/internal/compliance/engine_test.go
index 9e645e2..eac960c 100644
--- a/internal/compliance/engine_test.go
+++ b/internal/compliance/engine_test.go
@@ -52,12 +52,12 @@ type staticRule struct {
evalFn func(GraphQuerier) ([]RuleResult, error)
}
-func (r *staticRule) ID() string { return r.id }
-func (r *staticRule) Framework() Framework { return r.framework }
-func (r *staticRule) Module() string { return r.module }
-func (r *staticRule) Article() string { return r.article }
-func (r *staticRule) Title() string { return r.title }
-func (r *staticRule) Severity() Severity { return r.severity }
+func (r *staticRule) ID() string { return r.id }
+func (r *staticRule) Framework() Framework { return r.framework }
+func (r *staticRule) Module() string { return r.module }
+func (r *staticRule) Article() string { return r.article }
+func (r *staticRule) Title() string { return r.title }
+func (r *staticRule) Severity() Severity { return r.severity }
func (r *staticRule) Evaluate(g GraphQuerier) ([]RuleResult, error) { return r.evalFn(g) }
func TestEngineRegisterAndRules(t *testing.T) {
@@ -121,3 +121,238 @@ func TestSeverityWeight(t *testing.T) {
}
}
}
+
+func TestEngineRunScanStoresResults(t *testing.T) {
+ g := newTestGraph(t)
+ e := NewEngine(g)
+
+ passRule := &staticRule{
+ id: "PASS-001", framework: FrameworkGDPR, module: "ops",
+ article: "Art. 1", title: "pass", severity: SeverityHigh,
+ evalFn: func(GraphQuerier) ([]RuleResult, error) {
+ return []RuleResult{{
+ RuleID: "PASS-001",
+ Status: EvalPass,
+ NodeID: "node-1",
+ Details: "ok",
+ Severity: SeverityHigh,
+ }}, nil
+ },
+ }
+ naRule := &staticRule{
+ id: "NA-001", framework: FrameworkGDPR, module: "ops",
+ article: "Art. 2", title: "na", severity: SeverityCritical,
+ evalFn: func(GraphQuerier) ([]RuleResult, error) {
+ return nil, nil
+ },
+ }
+ warnRule := &staticRule{
+ id: "WARN-001", framework: FrameworkGDPR, module: "ops",
+ article: "Art. 3", title: "warn", severity: SeverityMedium,
+ evalFn: func(GraphQuerier) ([]RuleResult, error) {
+ return nil, context.DeadlineExceeded
+ },
+ }
+ filteredRule := &staticRule{
+ id: "SKIP-001", framework: FrameworkSOC2, module: "ops",
+ article: "Art. 4", title: "skip", severity: SeverityLow,
+ evalFn: func(GraphQuerier) ([]RuleResult, error) {
+ t.Fatal("filtered rule should not be evaluated")
+ return nil, nil
+ },
+ }
+
+ e.RegisterRule(passRule)
+ e.RegisterRule(naRule)
+ e.RegisterRule(warnRule)
+ e.RegisterRule(filteredRule)
+
+ if err := e.EnsureFrameworkNodes(); err != nil {
+ t.Fatalf("EnsureFrameworkNodes: %v", err)
+ }
+
+ result, err := e.RunScan(context.Background(), ScanRequest{
+ Framework: FrameworkGDPR,
+ Module: "ops",
+ })
+ if err != nil {
+ t.Fatalf("RunScan: %v", err)
+ }
+
+ if result.ScanID == "" {
+ t.Fatal("expected scan ID")
+ }
+ if result.PassCount != 1 || result.WarningCount != 1 || result.NACount != 1 || result.FailCount != 0 {
+ t.Fatalf("unexpected counts: %+v", result)
+ }
+ if got, want := result.Score, CalculateScore(result.Evaluations); got != want {
+ t.Fatalf("score = %v, want %v", got, want)
+ }
+ if len(result.Evaluations) != 3 {
+ t.Fatalf("expected 3 evaluations, got %d", len(result.Evaluations))
+ }
+
+ scans, err := g.NodesByLabel("ComplianceScan")
+ if err != nil {
+ t.Fatalf("NodesByLabel(ComplianceScan): %v", err)
+ }
+ if len(scans) != 1 {
+ t.Fatalf("expected 1 ComplianceScan node, got %d", len(scans))
+ }
+
+ evals, err := g.NodesByLabel("ComplianceEvaluation")
+ if err != nil {
+ t.Fatalf("NodesByLabel(ComplianceEvaluation): %v", err)
+ }
+ if len(evals) != 3 {
+ t.Fatalf("expected 3 ComplianceEvaluation nodes, got %d", len(evals))
+ }
+
+ ruleNodes, err := g.NodesByLabel("ComplianceRule")
+ if err != nil {
+ t.Fatalf("NodesByLabel(ComplianceRule): %v", err)
+ }
+ if len(ruleNodes) != 4 {
+ t.Fatalf("expected 4 ComplianceRule nodes, got %d", len(ruleNodes))
+ }
+
+ scanEdges, err := g.Neighbors(scans[0].ID)
+ if err != nil {
+ t.Fatalf("Neighbors(scan): %v", err)
+ }
+ foundScope := false
+ for _, edge := range scanEdges {
+ if edge.Label == "SCOPED_TO" {
+ foundScope = true
+ break
+ }
+ }
+ if !foundScope {
+ t.Fatal("expected SCOPED_TO edge for scan")
+ }
+
+ foundPartOfScan := false
+ foundEvaluatedBy := false
+ for _, eval := range evals {
+ edges, err := g.Neighbors(eval.ID)
+ if err != nil {
+ t.Fatalf("Neighbors(eval): %v", err)
+ }
+ for _, edge := range edges {
+ if edge.Label == "PART_OF_SCAN" {
+ foundPartOfScan = true
+ }
+ if edge.Label == "EVALUATED_BY" {
+ foundEvaluatedBy = true
+ }
+ }
+ }
+ if !foundPartOfScan {
+ t.Fatal("expected PART_OF_SCAN edge")
+ }
+ if !foundEvaluatedBy {
+ t.Fatal("expected EVALUATED_BY edge")
+ }
+}
+
+func TestEngineRunScanCanceled(t *testing.T) {
+ g := newTestGraph(t)
+ e := NewEngine(g)
+ e.RegisterRule(&staticRule{
+ id: "CANCEL-001", framework: FrameworkGDPR, module: "ops", severity: SeverityHigh,
+ evalFn: func(GraphQuerier) ([]RuleResult, error) {
+ t.Fatal("rule should not run after cancellation")
+ return nil, nil
+ },
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := e.RunScan(ctx, ScanRequest{Framework: FrameworkGDPR, Module: "ops"})
+ if err != context.Canceled {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestEnsureFrameworkNodesIdempotent(t *testing.T) {
+ g := newTestGraph(t)
+ e := NewEngine(g)
+ e.RegisterRule(&staticRule{id: "R1", framework: FrameworkGDPR, module: "setup", article: "Art. 1", title: "one", severity: SeverityHigh})
+ e.RegisterRule(&staticRule{id: "R2", framework: FrameworkGDPR, module: "ops", article: "Art. 2", title: "two", severity: SeverityMedium})
+ e.RegisterRule(&staticRule{id: "R3", framework: FrameworkSOC2, module: "ops", article: "CC1", title: "three", severity: SeverityLow})
+
+ if err := e.EnsureFrameworkNodes(); err != nil {
+ t.Fatalf("first EnsureFrameworkNodes: %v", err)
+ }
+ if err := e.EnsureFrameworkNodes(); err != nil {
+ t.Fatalf("second EnsureFrameworkNodes: %v", err)
+ }
+
+ frameworks, err := g.NodesByLabel("ComplianceFramework")
+ if err != nil {
+ t.Fatalf("NodesByLabel(ComplianceFramework): %v", err)
+ }
+ if len(frameworks) != 2 {
+ t.Fatalf("expected 2 framework nodes, got %d", len(frameworks))
+ }
+
+ rules, err := g.NodesByLabel("ComplianceRule")
+ if err != nil {
+ t.Fatalf("NodesByLabel(ComplianceRule): %v", err)
+ }
+ if len(rules) != 3 {
+ t.Fatalf("expected 3 rule nodes, got %d", len(rules))
+ }
+
+ defineEdges := 0
+ for _, rule := range rules {
+ edges, err := g.Neighbors(rule.ID)
+ if err != nil {
+ t.Fatalf("Neighbors(rule): %v", err)
+ }
+ for _, edge := range edges {
+ if edge.Label == "DEFINES_RULE" {
+ defineEdges++
+ }
+ }
+ }
+ if defineEdges != 3 {
+ t.Fatalf("expected 3 DEFINES_RULE edges, got %d", defineEdges)
+ }
+}
+
+func TestGraphAdapter(t *testing.T) {
+ g := newTestGraph(t)
+ from, err := g.CreateNode("Agent", map[string]any{"name": "alice"})
+ if err != nil {
+ t.Fatalf("CreateNode(from): %v", err)
+ }
+ to, err := g.CreateNode("Agent", map[string]any{"name": "bob"})
+ if err != nil {
+ t.Fatalf("CreateNode(to): %v", err)
+ }
+ if _, err := g.CreateEdge("KNOWS", from.ID, to.ID, nil); err != nil {
+ t.Fatalf("CreateEdge: %v", err)
+ }
+
+ adapter := &graphAdapter{g: g}
+ nodes, err := adapter.NodesByLabel("Agent")
+ if err != nil {
+ t.Fatalf("NodesByLabel: %v", err)
+ }
+ if len(nodes) != 2 {
+ t.Fatalf("expected 2 nodes, got %d", len(nodes))
+ }
+
+ edges, err := adapter.Neighbors(string(from.ID))
+ if err != nil {
+ t.Fatalf("Neighbors: %v", err)
+ }
+ if len(edges) != 1 {
+ t.Fatalf("expected 1 edge, got %d", len(edges))
+ }
+ if edges[0].Label != "KNOWS" || edges[0].From != string(from.ID) || edges[0].To != string(to.ID) {
+ t.Fatalf("unexpected edge: %+v", edges[0])
+ }
+}
diff --git a/internal/compliance/hardcoded.go b/internal/compliance/hardcoded.go
index 7767e9e..8c0ea92 100644
--- a/internal/compliance/hardcoded.go
+++ b/internal/compliance/hardcoded.go
@@ -47,12 +47,12 @@ func RegisterGDPRRules(e *Engine) {
type gdprSetup001 struct{}
-func (r *gdprSetup001) ID() string { return "GDPR-SETUP-001" }
+func (r *gdprSetup001) ID() string { return "GDPR-SETUP-001" }
func (r *gdprSetup001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprSetup001) Module() string { return "setup" }
-func (r *gdprSetup001) Article() string { return "Art. 37" }
-func (r *gdprSetup001) Title() string { return "DPO designation required" }
-func (r *gdprSetup001) Severity() Severity { return SeverityCritical }
+func (r *gdprSetup001) Module() string { return "setup" }
+func (r *gdprSetup001) Article() string { return "Art. 37" }
+func (r *gdprSetup001) Title() string { return "DPO designation required" }
+func (r *gdprSetup001) Severity() Severity { return SeverityCritical }
func (r *gdprSetup001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("OrgSetup")
@@ -92,12 +92,12 @@ func (r *gdprSetup001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprRopa001 struct{}
-func (r *gdprRopa001) ID() string { return "GDPR-ROPA-001" }
+func (r *gdprRopa001) ID() string { return "GDPR-ROPA-001" }
func (r *gdprRopa001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprRopa001) Module() string { return "ropa" }
-func (r *gdprRopa001) Article() string { return "Art. 6" }
-func (r *gdprRopa001) Title() string { return "Legal basis required for all processing activities" }
-func (r *gdprRopa001) Severity() Severity { return SeverityCritical }
+func (r *gdprRopa001) Module() string { return "ropa" }
+func (r *gdprRopa001) Article() string { return "Art. 6" }
+func (r *gdprRopa001) Title() string { return "Legal basis required for all processing activities" }
+func (r *gdprRopa001) Severity() Severity { return SeverityCritical }
func (r *gdprRopa001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkPropertyNotEmpty(g, r.ID(), r.Severity(), "ProcessingActivity", "legalBasis")
@@ -107,12 +107,12 @@ func (r *gdprRopa001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprRopa002 struct{}
-func (r *gdprRopa002) ID() string { return "GDPR-ROPA-002" }
+func (r *gdprRopa002) ID() string { return "GDPR-ROPA-002" }
func (r *gdprRopa002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprRopa002) Module() string { return "ropa" }
-func (r *gdprRopa002) Article() string { return "Art. 30" }
-func (r *gdprRopa002) Title() string { return "Retention period required for processing activities" }
-func (r *gdprRopa002) Severity() Severity { return SeverityHigh }
+func (r *gdprRopa002) Module() string { return "ropa" }
+func (r *gdprRopa002) Article() string { return "Art. 30" }
+func (r *gdprRopa002) Title() string { return "Retention period required for processing activities" }
+func (r *gdprRopa002) Severity() Severity { return SeverityHigh }
func (r *gdprRopa002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkPropertyNotEmpty(g, r.ID(), r.Severity(), "ProcessingActivity", "retentionPeriod")
@@ -122,12 +122,12 @@ func (r *gdprRopa002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprRopa003 struct{}
-func (r *gdprRopa003) ID() string { return "GDPR-ROPA-003" }
+func (r *gdprRopa003) ID() string { return "GDPR-ROPA-003" }
func (r *gdprRopa003) Framework() Framework { return FrameworkGDPR }
-func (r *gdprRopa003) Module() string { return "ropa" }
-func (r *gdprRopa003) Article() string { return "Art. 30" }
-func (r *gdprRopa003) Title() string { return "Data categories must be documented per activity" }
-func (r *gdprRopa003) Severity() Severity { return SeverityMedium }
+func (r *gdprRopa003) Module() string { return "ropa" }
+func (r *gdprRopa003) Article() string { return "Art. 30" }
+func (r *gdprRopa003) Title() string { return "Data categories must be documented per activity" }
+func (r *gdprRopa003) Severity() Severity { return SeverityMedium }
func (r *gdprRopa003) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkHasEdge(g, r.ID(), r.Severity(), "ProcessingActivity", "PROCESSES_CATEGORY")
@@ -137,12 +137,12 @@ func (r *gdprRopa003) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprRopa004 struct{}
-func (r *gdprRopa004) ID() string { return "GDPR-ROPA-004" }
+func (r *gdprRopa004) ID() string { return "GDPR-ROPA-004" }
func (r *gdprRopa004) Framework() Framework { return FrameworkGDPR }
-func (r *gdprRopa004) Module() string { return "ropa" }
-func (r *gdprRopa004) Article() string { return "Art. 32" }
-func (r *gdprRopa004) Title() string { return "Technical/organizational measures required" }
-func (r *gdprRopa004) Severity() Severity { return SeverityHigh }
+func (r *gdprRopa004) Module() string { return "ropa" }
+func (r *gdprRopa004) Article() string { return "Art. 32" }
+func (r *gdprRopa004) Title() string { return "Technical/organizational measures required" }
+func (r *gdprRopa004) Severity() Severity { return SeverityHigh }
func (r *gdprRopa004) Evaluate(g GraphQuerier) ([]RuleResult, error) {
return checkHasEdge(g, r.ID(), r.Severity(), "ProcessingActivity", "PROTECTED_BY")
@@ -152,12 +152,12 @@ func (r *gdprRopa004) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprDSR001 struct{}
-func (r *gdprDSR001) ID() string { return "GDPR-DSR-001" }
+func (r *gdprDSR001) ID() string { return "GDPR-DSR-001" }
func (r *gdprDSR001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprDSR001) Module() string { return "dsr" }
-func (r *gdprDSR001) Article() string { return "Art. 12" }
-func (r *gdprDSR001) Title() string { return "No DSR requests overdue" }
-func (r *gdprDSR001) Severity() Severity { return SeverityCritical }
+func (r *gdprDSR001) Module() string { return "dsr" }
+func (r *gdprDSR001) Article() string { return "Art. 12" }
+func (r *gdprDSR001) Title() string { return "No DSR requests overdue" }
+func (r *gdprDSR001) Severity() Severity { return SeverityCritical }
func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("DataSubjectRequest")
@@ -197,7 +197,7 @@ func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if now.After(deadline) {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: fmt.Sprintf("DSR overdue (deadline: %s)", deadlineStr),
+ Details: fmt.Sprintf("DSR overdue (deadline: %s)", deadlineStr),
Severity: r.Severity(),
})
} else {
@@ -214,12 +214,12 @@ func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprDSR002 struct{}
-func (r *gdprDSR002) ID() string { return "GDPR-DSR-002" }
+func (r *gdprDSR002) ID() string { return "GDPR-DSR-002" }
func (r *gdprDSR002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprDSR002) Module() string { return "dsr" }
-func (r *gdprDSR002) Article() string { return "Art. 15-22" }
-func (r *gdprDSR002) Title() string { return "Completed DSRs must have response details" }
-func (r *gdprDSR002) Severity() Severity { return SeverityHigh }
+func (r *gdprDSR002) Module() string { return "dsr" }
+func (r *gdprDSR002) Article() string { return "Art. 15-22" }
+func (r *gdprDSR002) Title() string { return "Completed DSRs must have response details" }
+func (r *gdprDSR002) Severity() Severity { return SeverityHigh }
func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("DataSubjectRequest")
@@ -236,7 +236,7 @@ func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if resp == "" {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: "Completed DSR missing responseDetails",
+ Details: "Completed DSR missing responseDetails",
Severity: r.Severity(),
})
} else {
@@ -253,12 +253,12 @@ func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprBreach001 struct{}
-func (r *gdprBreach001) ID() string { return "GDPR-BREACH-001" }
+func (r *gdprBreach001) ID() string { return "GDPR-BREACH-001" }
func (r *gdprBreach001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprBreach001) Module() string { return "breach" }
-func (r *gdprBreach001) Article() string { return "Art. 33" }
-func (r *gdprBreach001) Title() string { return "Authority notification within 72 hours" }
-func (r *gdprBreach001) Severity() Severity { return SeverityCritical }
+func (r *gdprBreach001) Module() string { return "breach" }
+func (r *gdprBreach001) Article() string { return "Art. 33" }
+func (r *gdprBreach001) Title() string { return "Authority notification within 72 hours" }
+func (r *gdprBreach001) Severity() Severity { return SeverityCritical }
func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("DataBreach")
@@ -271,7 +271,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if sev != "high" && sev != "critical" {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalPass, NodeID: n.ID,
- Details: "Low/medium breach — 72h rule not mandatory",
+ Details: "Low/medium breach — 72h rule not mandatory",
Severity: r.Severity(),
})
continue
@@ -281,7 +281,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if notifiedStr == "" {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: "High/critical breach not yet notified to authority",
+ Details: "High/critical breach not yet notified to authority",
Severity: r.Severity(),
})
continue
@@ -291,7 +291,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if err1 != nil || err2 != nil {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalWarning, NodeID: n.ID,
- Details: "Cannot parse breach timestamps",
+ Details: "Cannot parse breach timestamps",
Severity: r.Severity(),
})
continue
@@ -299,13 +299,13 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if notified.Sub(discovered) > 72*time.Hour {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: fmt.Sprintf("Notification took %s (> 72h)", notified.Sub(discovered)),
+ Details: fmt.Sprintf("Notification took %s (> 72h)", notified.Sub(discovered)),
Severity: r.Severity(),
})
} else {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalPass, NodeID: n.ID,
- Details: "Authority notified within 72h",
+ Details: "Authority notified within 72h",
Severity: r.Severity(),
})
}
@@ -317,12 +317,12 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprBreach002 struct{}
-func (r *gdprBreach002) ID() string { return "GDPR-BREACH-002" }
+func (r *gdprBreach002) ID() string { return "GDPR-BREACH-002" }
func (r *gdprBreach002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprBreach002) Module() string { return "breach" }
-func (r *gdprBreach002) Article() string { return "Art. 34" }
-func (r *gdprBreach002) Title() string { return "Data subjects notified for special category breaches" }
-func (r *gdprBreach002) Severity() Severity { return SeverityCritical }
+func (r *gdprBreach002) Module() string { return "breach" }
+func (r *gdprBreach002) Article() string { return "Art. 34" }
+func (r *gdprBreach002) Title() string { return "Data subjects notified for special category breaches" }
+func (r *gdprBreach002) Severity() Severity { return SeverityCritical }
func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("DataBreach")
@@ -343,7 +343,7 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if !involvesSpecial {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalPass, NodeID: n.ID,
- Details: "Breach does not involve special categories",
+ Details: "Breach does not involve special categories",
Severity: r.Severity(),
})
continue
@@ -352,7 +352,7 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
if notifiedStr == "" {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: "Breach involves special categories but subjects not notified",
+ Details: "Breach involves special categories but subjects not notified",
Severity: r.Severity(),
})
} else {
@@ -369,12 +369,12 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprDPIA001 struct{}
-func (r *gdprDPIA001) ID() string { return "GDPR-DPIA-001" }
+func (r *gdprDPIA001) ID() string { return "GDPR-DPIA-001" }
func (r *gdprDPIA001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprDPIA001) Module() string { return "dpia" }
-func (r *gdprDPIA001) Article() string { return "Art. 35" }
-func (r *gdprDPIA001) Title() string { return "DPIA required for high-risk processing" }
-func (r *gdprDPIA001) Severity() Severity { return SeverityCritical }
+func (r *gdprDPIA001) Module() string { return "dpia" }
+func (r *gdprDPIA001) Article() string { return "Art. 35" }
+func (r *gdprDPIA001) Title() string { return "DPIA required for high-risk processing" }
+func (r *gdprDPIA001) Severity() Severity { return SeverityCritical }
func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
activities, err := g.NodesByLabel("ProcessingActivity")
@@ -408,7 +408,7 @@ func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
} else {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: a.ID,
- Details: "High-risk activity missing DPIA",
+ Details: "High-risk activity missing DPIA",
Severity: r.Severity(),
})
}
@@ -420,12 +420,12 @@ func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprDPIA002 struct{}
-func (r *gdprDPIA002) ID() string { return "GDPR-DPIA-002" }
+func (r *gdprDPIA002) ID() string { return "GDPR-DPIA-002" }
func (r *gdprDPIA002) Framework() Framework { return FrameworkGDPR }
-func (r *gdprDPIA002) Module() string { return "dpia" }
-func (r *gdprDPIA002) Article() string { return "Art. 35" }
-func (r *gdprDPIA002) Title() string { return "Every DPIA must identify risks" }
-func (r *gdprDPIA002) Severity() Severity { return SeverityHigh }
+func (r *gdprDPIA002) Module() string { return "dpia" }
+func (r *gdprDPIA002) Article() string { return "Art. 35" }
+func (r *gdprDPIA002) Title() string { return "Every DPIA must identify risks" }
+func (r *gdprDPIA002) Severity() Severity { return SeverityHigh }
func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
dpias, err := g.NodesByLabel("DPIA")
@@ -450,7 +450,7 @@ func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
} else {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: d.ID,
- Details: "DPIA has no identified risks",
+ Details: "DPIA has no identified risks",
Severity: r.Severity(),
})
}
@@ -462,12 +462,12 @@ func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprProc001 struct{}
-func (r *gdprProc001) ID() string { return "GDPR-PROC-001" }
+func (r *gdprProc001) ID() string { return "GDPR-PROC-001" }
func (r *gdprProc001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprProc001) Module() string { return "processor" }
-func (r *gdprProc001) Article() string { return "Art. 28" }
-func (r *gdprProc001) Title() string { return "Active processors must have signed contracts" }
-func (r *gdprProc001) Severity() Severity { return SeverityHigh }
+func (r *gdprProc001) Module() string { return "processor" }
+func (r *gdprProc001) Article() string { return "Art. 28" }
+func (r *gdprProc001) Title() string { return "Active processors must have signed contracts" }
+func (r *gdprProc001) Severity() Severity { return SeverityHigh }
func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("DataProcessor")
@@ -485,7 +485,7 @@ func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
} else {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: fmt.Sprintf("Processor contract status: %s", status),
+ Details: fmt.Sprintf("Processor contract status: %s", status),
Severity: r.Severity(),
})
}
@@ -497,12 +497,12 @@ func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
type gdprEvidence001 struct{}
-func (r *gdprEvidence001) ID() string { return "GDPR-EVIDENCE-001" }
+func (r *gdprEvidence001) ID() string { return "GDPR-EVIDENCE-001" }
func (r *gdprEvidence001) Framework() Framework { return FrameworkGDPR }
-func (r *gdprEvidence001) Module() string { return "evidence" }
-func (r *gdprEvidence001) Article() string { return "Art. 5(2)" }
-func (r *gdprEvidence001) Title() string { return "Compliant checklist items must have evidence" }
-func (r *gdprEvidence001) Severity() Severity { return SeverityMedium }
+func (r *gdprEvidence001) Module() string { return "evidence" }
+func (r *gdprEvidence001) Article() string { return "Art. 5(2)" }
+func (r *gdprEvidence001) Title() string { return "Compliant checklist items must have evidence" }
+func (r *gdprEvidence001) Severity() Severity { return SeverityMedium }
func (r *gdprEvidence001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
nodes, err := g.NodesByLabel("ChecklistItem")
@@ -531,7 +531,7 @@ func (r *gdprEvidence001) Evaluate(g GraphQuerier) ([]RuleResult, error) {
} else {
results = append(results, RuleResult{
RuleID: r.ID(), Status: EvalFail, NodeID: n.ID,
- Details: "Compliant checklist item has no evidence attached",
+ Details: "Compliant checklist item has no evidence attached",
Severity: r.Severity(),
})
}
@@ -588,13 +588,13 @@ func checkHasEdge(g GraphQuerier, ruleID string, sev Severity, label, edgeLabel
if found {
results = append(results, RuleResult{
RuleID: ruleID, Status: EvalPass, NodeID: n.ID,
- Details: fmt.Sprintf("%s has %s edge", label, edgeLabel),
+ Details: fmt.Sprintf("%s has %s edge", label, edgeLabel),
Severity: sev,
})
} else {
results = append(results, RuleResult{
RuleID: ruleID, Status: EvalFail, NodeID: n.ID,
- Details: fmt.Sprintf("%s missing %s edge", label, edgeLabel),
+ Details: fmt.Sprintf("%s missing %s edge", label, edgeLabel),
Severity: sev,
})
}
diff --git a/internal/compliance/hardcoded_test.go b/internal/compliance/hardcoded_test.go
index fbd0964..ffea617 100644
--- a/internal/compliance/hardcoded_test.go
+++ b/internal/compliance/hardcoded_test.go
@@ -182,3 +182,93 @@ func TestRegisterGDPRRules(t *testing.T) {
t.Fatalf("expected 13 GDPR rules, got %d", len(rules))
}
}
+
+func TestGDPRWrapperRulesAndEvidence(t *testing.T) {
+ q := newMockQuerier()
+ q.nodes["ProcessingActivity"] = NodeList{
+ {ID: "n:PA:1", Label: "ProcessingActivity", Properties: map[string]any{"retentionPeriod": "30d", "riskLevel": "high"}},
+ {ID: "n:PA:2", Label: "ProcessingActivity", Properties: map[string]any{}},
+ }
+ q.nodes["DataSubjectRequest"] = NodeList{
+ {ID: "n:DSR:1", Properties: map[string]any{"status": "completed", "responseDetails": "emailed data export"}},
+ {ID: "n:DSR:2", Properties: map[string]any{"status": "completed"}},
+ }
+ q.nodes["DPIA"] = NodeList{
+ {ID: "n:DPIA:1", Properties: map[string]any{}},
+ {ID: "n:DPIA:2", Properties: map[string]any{}},
+ }
+ q.nodes["ChecklistItem"] = NodeList{
+ {ID: "n:CL:1", Properties: map[string]any{"status": "compliant"}},
+ {ID: "n:CL:2", Properties: map[string]any{"status": "compliant"}},
+ }
+ q.edges["n:PA:1"] = EdgeList{
+ {Label: "PROCESSES_CATEGORY", To: "n:Cat:1"},
+ {Label: "PROTECTED_BY", To: "n:SM:1"},
+ }
+ q.edges["n:DPIA:1"] = EdgeList{{Label: "DPIA_FOR", To: "n:PA:1"}}
+ q.edges["n:DPIA:2"] = EdgeList{{Label: "HAS_RISK", To: "n:Risk:1"}}
+ q.edges["n:CL:1"] = EdgeList{{Label: "EVIDENCED_BY", To: "n:EV:1"}}
+
+ tests := []struct {
+ name string
+ rule Rule
+ wantCount int
+ statuses []EvalStatus
+ }{
+ {"ropa002", &gdprRopa002{}, 2, []EvalStatus{EvalPass, EvalFail}},
+ {"ropa003", &gdprRopa003{}, 2, []EvalStatus{EvalPass, EvalFail}},
+ {"ropa004", &gdprRopa004{}, 2, []EvalStatus{EvalPass, EvalFail}},
+ {"dsr002", &gdprDSR002{}, 2, []EvalStatus{EvalPass, EvalFail}},
+ {"dpia001", &gdprDPIA001{}, 1, []EvalStatus{EvalPass}},
+ {"dpia002", &gdprDPIA002{}, 2, []EvalStatus{EvalFail, EvalPass}},
+ {"evidence001", &gdprEvidence001{}, 2, []EvalStatus{EvalPass, EvalFail}},
+ }
+
+ for _, tt := range tests {
+ results, err := tt.rule.Evaluate(q)
+ if err != nil {
+ t.Fatalf("%s Evaluate: %v", tt.name, err)
+ }
+ if len(results) != tt.wantCount {
+ t.Fatalf("%s expected %d results, got %d", tt.name, tt.wantCount, len(results))
+ }
+ for i, status := range tt.statuses {
+ if results[i].Status != status {
+ t.Fatalf("%s result[%d] = %s, want %s", tt.name, i, results[i].Status, status)
+ }
+ }
+ }
+}
+
+func TestGDPRBreach002AndFlow004Evaluate(t *testing.T) {
+ q := newMockQuerier()
+ q.nodes["DataBreach"] = NodeList{
+ {ID: "n:Breach:1", Properties: map[string]any{}},
+ {ID: "n:Breach:2", Properties: map[string]any{"subjectsNotifiedAt": time.Now().UTC().Format(time.RFC3339)}},
+ }
+ q.edges["n:Breach:2"] = EdgeList{{Label: "BREACH_INVOLVES", To: "n:Cat:1"}}
+
+ breachResults, err := (&gdprBreach002{}).Evaluate(q)
+ if err != nil {
+ t.Fatalf("gdprBreach002 Evaluate: %v", err)
+ }
+ if len(breachResults) != 2 {
+ t.Fatalf("expected 2 breach results, got %d", len(breachResults))
+ }
+ if breachResults[0].Status != EvalPass || breachResults[1].Status != EvalPass {
+ t.Fatalf("unexpected breach statuses: %+v", breachResults)
+ }
+
+ q.nodes["DataFlow"] = NodeList{
+ {ID: "n:Flow:1", Properties: map[string]any{"legalBasis": "legitimate_interest"}},
+ }
+ q.edges["n:Flow:1"] = EdgeList{{Label: "CARRIES", To: "n:Cat:2"}}
+
+ flowResults, err := (&gdprFlow004{}).Evaluate(q)
+ if err != nil {
+ t.Fatalf("gdprFlow004 Evaluate: %v", err)
+ }
+ if len(flowResults) != 0 {
+ t.Fatalf("expected no flow004 results, got %d", len(flowResults))
+ }
+}
diff --git a/internal/compliance/inspection.go b/internal/compliance/inspection.go
index 0dc6223..741fe25 100644
--- a/internal/compliance/inspection.go
+++ b/internal/compliance/inspection.go
@@ -25,6 +25,7 @@ import (
// InspectionStatus tracks the lifecycle of an inspection.
type InspectionStatus string
+// Inspection lifecycle states.
const (
InspectionDraft InspectionStatus = "draft"
InspectionInProgress InspectionStatus = "in_progress"
@@ -36,6 +37,7 @@ const (
// FindingStatus tracks the lifecycle of an inspection finding.
type FindingStatus string
+// Finding lifecycle states.
const (
FindingOpen FindingStatus = "open"
FindingRemediated FindingStatus = "remediated"
@@ -46,6 +48,7 @@ const (
// RemediationStatus tracks the lifecycle of a remediation action.
type RemediationStatus string
+// Remediation lifecycle states.
const (
RemediationPending RemediationStatus = "pending"
RemediationInProgress RemediationStatus = "in_progress"
@@ -111,8 +114,8 @@ func SignOffInspection(g *graph.Graph, inspectionID graph.NodeID, approverID str
}
_, err = g.UpsertNode(inspectionID, node.Label, graph.Properties{
- "status": string(InspectionSignedOff),
- "approverId": approverID,
+ "status": string(InspectionSignedOff),
+ "approverId": approverID,
"signedOffAt": time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
diff --git a/internal/compliance/rule_metadata_test.go b/internal/compliance/rule_metadata_test.go
new file mode 100644
index 0000000..c6a2d03
--- /dev/null
+++ b/internal/compliance/rule_metadata_test.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Scalytics, Inc.
+// Copyright 2026 Mirko Kämpf
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compliance
+
+import "testing"
+
+func TestRegisterRuleSets(t *testing.T) {
+ e := NewEngine(nil)
+ RegisterGDPRRules(e)
+ RegisterDataFlowRules(e)
+
+ rules := e.Rules()
+ if len(rules) != 20 {
+ t.Fatalf("expected 20 registered rules, got %d", len(rules))
+ }
+
+ if got := len(e.RulesByFramework(FrameworkGDPR)); got != 20 {
+ t.Fatalf("expected 20 GDPR rules, got %d", got)
+ }
+}
+
+func TestBuiltInRuleMetadata(t *testing.T) {
+ tests := []struct {
+ rule Rule
+ id string
+ framework Framework
+ module string
+ article string
+ title string
+ severity Severity
+ }{
+ {&gdprSetup001{}, "GDPR-SETUP-001", FrameworkGDPR, "setup", "Art. 37", "DPO designation required", SeverityCritical},
+ {&gdprRopa001{}, "GDPR-ROPA-001", FrameworkGDPR, "ropa", "Art. 6", "Legal basis required for all processing activities", SeverityCritical},
+ {&gdprRopa002{}, "GDPR-ROPA-002", FrameworkGDPR, "ropa", "Art. 30", "Retention period required for processing activities", SeverityHigh},
+ {&gdprRopa003{}, "GDPR-ROPA-003", FrameworkGDPR, "ropa", "Art. 30", "Data categories must be documented per activity", SeverityMedium},
+ {&gdprRopa004{}, "GDPR-ROPA-004", FrameworkGDPR, "ropa", "Art. 32", "Technical/organizational measures required", SeverityHigh},
+ {&gdprDSR001{}, "GDPR-DSR-001", FrameworkGDPR, "dsr", "Art. 12", "No DSR requests overdue", SeverityCritical},
+ {&gdprDSR002{}, "GDPR-DSR-002", FrameworkGDPR, "dsr", "Art. 15-22", "Completed DSRs must have response details", SeverityHigh},
+ {&gdprBreach001{}, "GDPR-BREACH-001", FrameworkGDPR, "breach", "Art. 33", "Authority notification within 72 hours", SeverityCritical},
+ {&gdprBreach002{}, "GDPR-BREACH-002", FrameworkGDPR, "breach", "Art. 34", "Data subjects notified for special category breaches", SeverityCritical},
+ {&gdprDPIA001{}, "GDPR-DPIA-001", FrameworkGDPR, "dpia", "Art. 35", "DPIA required for high-risk processing", SeverityCritical},
+ {&gdprDPIA002{}, "GDPR-DPIA-002", FrameworkGDPR, "dpia", "Art. 35", "Every DPIA must identify risks", SeverityHigh},
+ {&gdprProc001{}, "GDPR-PROC-001", FrameworkGDPR, "processor", "Art. 28", "Active processors must have signed contracts", SeverityHigh},
+ {&gdprEvidence001{}, "GDPR-EVIDENCE-001", FrameworkGDPR, "evidence", "Art. 5(2)", "Compliant checklist items must have evidence", SeverityMedium},
+ {&gdprFlow001{}, "GDPR-FLOW-001", FrameworkGDPR, "dataflow", "Art. 30", "Data categories must be documented per data flow", SeverityHigh},
+ {&gdprFlow002{}, "GDPR-FLOW-002", FrameworkGDPR, "dataflow", "Art. 6", "Legal basis required for every data flow", SeverityCritical},
+ {&gdprFlow003{}, "GDPR-FLOW-003", FrameworkGDPR, "dataflow", "Art. 44-49", "International transfers require adequate safeguards", SeverityCritical},
+ {&gdprFlow004{}, "GDPR-FLOW-004", FrameworkGDPR, "dataflow", "Art. 9", "Special category data flows require explicit consent", SeverityCritical},
+ {&gdprFlow005{}, "GDPR-FLOW-005", FrameworkGDPR, "dataflow", "Art. 30", "Active processing activities should have data flows defined", SeverityMedium},
+ {&gdprInsp001{}, "GDPR-INSP-001", FrameworkGDPR, "inspection", "Art. 5(2)", "No inspection findings overdue", SeverityHigh},
+ {&gdprInsp002{}, "GDPR-INSP-002", FrameworkGDPR, "inspection", "Art. 5(2)", "Completed remediations must be verified", SeverityMedium},
+ }
+
+ for _, tt := range tests {
+ if got := tt.rule.ID(); got != tt.id {
+ t.Fatalf("%T ID() = %q, want %q", tt.rule, got, tt.id)
+ }
+ if got := tt.rule.Framework(); got != tt.framework {
+ t.Fatalf("%T Framework() = %q, want %q", tt.rule, got, tt.framework)
+ }
+ if got := tt.rule.Module(); got != tt.module {
+ t.Fatalf("%T Module() = %q, want %q", tt.rule, got, tt.module)
+ }
+ if got := tt.rule.Article(); got != tt.article {
+ t.Fatalf("%T Article() = %q, want %q", tt.rule, got, tt.article)
+ }
+ if got := tt.rule.Title(); got != tt.title {
+ t.Fatalf("%T Title() = %q, want %q", tt.rule, got, tt.title)
+ }
+ if got := tt.rule.Severity(); got != tt.severity {
+ t.Fatalf("%T Severity() = %q, want %q", tt.rule, got, tt.severity)
+ }
+ }
+}
+
+func TestYAMLRuleMetadataAndSeverityFallback(t *testing.T) {
+ def := &YAMLRuleDef{
+ RuleID: "YAML-001",
+ FrameworkStr: "gdpr",
+ ModuleStr: "yaml",
+ ArticleStr: "Art. 99",
+ TitleStr: "YAML title",
+ SeverityStr: "unknown",
+ }
+
+ rule := def.ToRule()
+ if rule.ID() != "YAML-001" || rule.Framework() != FrameworkGDPR || rule.Module() != "yaml" {
+ t.Fatalf("unexpected yaml rule metadata: %+v", rule)
+ }
+ if rule.Article() != "Art. 99" || rule.Title() != "YAML title" {
+ t.Fatalf("unexpected yaml article/title: %q %q", rule.Article(), rule.Title())
+ }
+ if rule.Severity() != SeverityMedium {
+ t.Fatalf("unexpected default severity: %q", rule.Severity())
+ }
+}
diff --git a/internal/compliance/scheduler.go b/internal/compliance/scheduler.go
index a91a28f..194c4e7 100644
--- a/internal/compliance/scheduler.go
+++ b/internal/compliance/scheduler.go
@@ -37,7 +37,7 @@ func NewScheduler(engine *Engine, interval time.Duration, autoScan bool) *Schedu
}
}
-// Run starts the scheduled scan loop. Blocks until ctx is cancelled.
+// Run starts the scheduled scan loop. Blocks until ctx is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
if !s.autoScan || s.interval <= 0 {
<-ctx.Done()
diff --git a/internal/compliance/scoring_test.go b/internal/compliance/scoring_test.go
index d4c7e1f..f91085a 100644
--- a/internal/compliance/scoring_test.go
+++ b/internal/compliance/scoring_test.go
@@ -52,7 +52,7 @@ func TestCalculateScore_AllFail(t *testing.T) {
func TestCalculateScore_Mixed(t *testing.T) {
results := []RuleResult{
{RuleID: "R1", Status: EvalPass, Severity: SeverityCritical}, // weight 3
- {RuleID: "R2", Status: EvalFail, Severity: SeverityLow}, // weight 0.5
+ {RuleID: "R2", Status: EvalFail, Severity: SeverityLow}, // weight 0.5
}
// Expected: 3 / 3.5 * 100 = 85.7
score := CalculateScore(results)
diff --git a/internal/compliance/types.go b/internal/compliance/types.go
index abdde8a..a02c2ad 100644
--- a/internal/compliance/types.go
+++ b/internal/compliance/types.go
@@ -20,6 +20,7 @@ import "time"
// Framework identifies a compliance framework.
type Framework string
+// Supported compliance frameworks.
const (
FrameworkGDPR Framework = "gdpr"
FrameworkSOC2 Framework = "soc2"
@@ -29,6 +30,7 @@ const (
// Severity levels for compliance rules.
type Severity string
+// Severity tiers from highest to lowest impact.
const (
SeverityCritical Severity = "critical"
SeverityHigh Severity = "high"
@@ -36,7 +38,7 @@ const (
SeverityLow Severity = "low"
)
-// SeverityWeight returns the numeric weight for score calculation.
+// Weight returns the numeric weight for score calculation.
func (s Severity) Weight() float64 {
switch s {
case SeverityCritical:
@@ -55,6 +57,7 @@ func (s Severity) Weight() float64 {
// EvalStatus is the result status of a rule evaluation.
type EvalStatus string
+// Possible rule evaluation outcomes.
const (
EvalPass EvalStatus = "pass"
EvalFail EvalStatus = "fail"
@@ -117,27 +120,27 @@ type ScanRequest struct {
// ScanResult is the aggregated result of a compliance scan.
type ScanResult struct {
- ScanID string `json:"scanId"`
- Framework Framework `json:"framework"`
- TriggeredBy string `json:"triggeredBy"`
- StartedAt time.Time `json:"startedAt"`
- CompletedAt time.Time `json:"completedAt"`
- PassCount int `json:"passCount"`
- FailCount int `json:"failCount"`
- WarningCount int `json:"warningCount"`
- NACount int `json:"naCount"`
- Score float64 `json:"score"`
- Evaluations []RuleResult `json:"evaluations"`
+ ScanID string `json:"scanId"`
+ Framework Framework `json:"framework"`
+ TriggeredBy string `json:"triggeredBy"`
+ StartedAt time.Time `json:"startedAt"`
+ CompletedAt time.Time `json:"completedAt"`
+ PassCount int `json:"passCount"`
+ FailCount int `json:"failCount"`
+ WarningCount int `json:"warningCount"`
+ NACount int `json:"naCount"`
+ Score float64 `json:"score"`
+ Evaluations []RuleResult `json:"evaluations"`
}
// ScoreSummary holds per-framework compliance scores.
type ScoreSummary struct {
- Framework Framework `json:"framework"`
- Score float64 `json:"score"`
- PassCount int `json:"passCount"`
- FailCount int `json:"failCount"`
- TotalRules int `json:"totalRules"`
- LastScanAt string `json:"lastScanAt,omitempty"`
+ Framework Framework `json:"framework"`
+ Score float64 `json:"score"`
+ PassCount int `json:"passCount"`
+ FailCount int `json:"failCount"`
+ TotalRules int `json:"totalRules"`
+ LastScanAt string `json:"lastScanAt,omitempty"`
}
// DashboardData aggregates data for the compliance dashboard.
diff --git a/internal/compliance/yaml_loader.go b/internal/compliance/yaml_loader.go
index 5619d36..c0e8d44 100644
--- a/internal/compliance/yaml_loader.go
+++ b/internal/compliance/yaml_loader.go
@@ -57,7 +57,7 @@ func (e *Engine) LoadYAMLRules(dir string) error {
}
func (e *Engine) loadYAMLFile(path string) error {
- data, err := os.ReadFile(path)
+ data, err := os.ReadFile(path) //nolint:gosec // path comes from filepath.WalkDir over operator-configured rules dir
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
diff --git a/internal/compliance/yaml_rule.go b/internal/compliance/yaml_rule.go
index e276ea9..50db912 100644
--- a/internal/compliance/yaml_rule.go
+++ b/internal/compliance/yaml_rule.go
@@ -42,11 +42,11 @@ type yamlRule struct {
def *YAMLRuleDef
}
-func (r *yamlRule) ID() string { return r.def.RuleID }
+func (r *yamlRule) ID() string { return r.def.RuleID }
func (r *yamlRule) Framework() Framework { return Framework(r.def.FrameworkStr) }
-func (r *yamlRule) Module() string { return r.def.ModuleStr }
-func (r *yamlRule) Article() string { return r.def.ArticleStr }
-func (r *yamlRule) Title() string { return r.def.TitleStr }
+func (r *yamlRule) Module() string { return r.def.ModuleStr }
+func (r *yamlRule) Article() string { return r.def.ArticleStr }
+func (r *yamlRule) Title() string { return r.def.TitleStr }
func (r *yamlRule) Severity() Severity {
switch Severity(r.def.SeverityStr) {
diff --git a/internal/demo/compliance_scenario.go b/internal/demo/compliance_scenario.go
index 19309f9..ac39117 100644
--- a/internal/demo/compliance_scenario.go
+++ b/internal/demo/compliance_scenario.go
@@ -206,8 +206,8 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) {
deadline time.Time
completed bool
}{
- {"access", "in_progress", now.Add(20 * 24 * time.Hour), false}, // Active, within SLA
- {"erasure", "pending", now.Add(-5 * 24 * time.Hour), false}, // Overdue!
+ {"access", "in_progress", now.Add(20 * 24 * time.Hour), false}, // Active, within SLA
+ {"erasure", "pending", now.Add(-5 * 24 * time.Hour), false}, // Overdue!
}
for _, d := range dsrs {
props := graph.Properties{
@@ -372,11 +372,11 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) {
transferType string
safeguard string
legalBasis string
- fromPA int // index into paNodes
- toPA int // -1 if to processor
- toProc int // index into procNodes; -1 if to activity
- catIdxs []int // data category indices
- lbIdx int // legal basis index
+ fromPA int // index into paNodes
+ toPA int // -1 if to processor
+ toProc int // index into procNodes; -1 if to activity
+ catIdxs []int // data category indices
+ lbIdx int // legal basis index
}{
{
name: "Internal Analytics Pipeline", transferType: "internal",
@@ -444,11 +444,11 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) {
// Finding 1: remediated
finding1, err := g.CreateNode("InspectionFinding", graph.Properties{
- "title": "Missing retention period for Employee Health Monitoring",
- "severity": "high",
- "status": "remediated",
- "targetDate": now.Add(30 * 24 * time.Hour).Format(time.RFC3339),
- "createdAt": now.Format(time.RFC3339),
+ "title": "Missing retention period for Employee Health Monitoring",
+ "severity": "high",
+ "status": "remediated",
+ "targetDate": now.Add(30 * 24 * time.Hour).Format(time.RFC3339),
+ "createdAt": now.Format(time.RFC3339),
})
if err != nil {
return nil, fmt.Errorf("create finding 1: %w", err)
diff --git a/internal/demo/scenario_test.go b/internal/demo/scenario_test.go
index d97ecb7..295d2d8 100644
--- a/internal/demo/scenario_test.go
+++ b/internal/demo/scenario_test.go
@@ -77,10 +77,10 @@ func TestBlogTeamScenarioIngestion(t *testing.T) {
// Verify node counts by label.
assertNodeCount(t, g, "Agent", 4)
assertNodeCount(t, g, "Conversation", 1)
- assertNodeCount(t, g, "Skill", 9) // web_search, summarize, deep_search, rewrite, tone_check, citation_check, ascii_doc, proofread, format_html
- assertNodeCount(t, g, "SharedMemory", 3) // research-findings, editorial-notes, final-blog
- assertNodeCount(t, g, "AuditEvent", 4) // 3 task_completed + 1 pipeline_completed
- assertMinNodeCount(t, g, "Message", 15) // requests + responses + skill_requests + skill_responses
+ assertNodeCount(t, g, "Skill", 9) // web_search, summarize, deep_search, rewrite, tone_check, citation_check, ascii_doc, proofread, format_html
+ assertNodeCount(t, g, "SharedMemory", 3) // research-findings, editorial-notes, final-blog
+ assertNodeCount(t, g, "AuditEvent", 4) // 3 task_completed + 1 pipeline_completed
+ assertMinNodeCount(t, g, "Message", 15) // requests + responses + skill_requests + skill_responses
}
func TestRunReflections(t *testing.T) {
diff --git a/internal/reflect/analyzer.go b/internal/reflect/analyzer.go
index 933ae65..c6864af 100644
--- a/internal/reflect/analyzer.go
+++ b/internal/reflect/analyzer.go
@@ -42,6 +42,7 @@ type AnalysisResult struct {
// EntityType classifies a recognized entity.
type EntityType string
+// Recognized entity classifications.
const (
EntityAgent EntityType = "agent"
EntitySkill EntityType = "skill"
diff --git a/internal/server/compliance_dataflow.go b/internal/server/compliance_dataflow.go
index de90dab..a9fd8f2 100644
--- a/internal/server/compliance_dataflow.go
+++ b/internal/server/compliance_dataflow.go
@@ -81,12 +81,12 @@ func handleDataFlowList(g *graph.Graph) http.HandlerFunc {
func handleDataFlowCreate(g *graph.Graph) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
- Props map[string]any `json:"properties"`
- FromActivity string `json:"fromActivityId"`
- ToActivity string `json:"toActivityId"`
- ToProcessor string `json:"toProcessorId"`
- CategoryIDs []string `json:"categoryIds"`
- LegalBasisID string `json:"legalBasisId"`
+ Props map[string]any `json:"properties"`
+ FromActivity string `json:"fromActivityId"`
+ ToActivity string `json:"toActivityId"`
+ ToProcessor string `json:"toProcessorId"`
+ CategoryIDs []string `json:"categoryIds"`
+ LegalBasisID string `json:"legalBasisId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
diff --git a/internal/server/compliance_dataflow_test.go b/internal/server/compliance_dataflow_test.go
new file mode 100644
index 0000000..c32b9bb
--- /dev/null
+++ b/internal/server/compliance_dataflow_test.go
@@ -0,0 +1,149 @@
+// Copyright 2026 Scalytics, Inc.
+// Copyright 2026 Mirko Kämpf
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/scalytics/kafgraph/internal/graph"
+)
+
+func complianceRequest(t *testing.T, tsURL, method, path, body string) *http.Response {
+ t.Helper()
+ var reqBody *strings.Reader
+ if body == "" {
+ reqBody = strings.NewReader("")
+ } else {
+ reqBody = strings.NewReader(body)
+ }
+ req, err := http.NewRequest(method, tsURL+path, reqBody)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ return resp
+}
+
+func decodeBody(t *testing.T, resp *http.Response) map[string]any {
+ t.Helper()
+ defer func() { _ = resp.Body.Close() }()
+ var body map[string]any
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ return body
+}
+
+func TestComplianceDataFlowCRUDAndValidate(t *testing.T) {
+ ts, _, g := setupComplianceTestServer(t)
+
+ src, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "HR Intake", "status": "active"})
+ require.NoError(t, err)
+ dst, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "Payroll", "status": "active"})
+ require.NoError(t, err)
+ proc, err := g.CreateNode("DataProcessor", graph.Properties{"name": "Vendor X"})
+ require.NoError(t, err)
+ cat, err := g.CreateNode("DataCategory", graph.Properties{"name": "Employee Data", "isSpecial": false})
+ require.NoError(t, err)
+ basis, err := g.CreateNode("LegalBasis", graph.Properties{"name": "Contract"})
+ require.NoError(t, err)
+ _, err = g.CreateNode("ProcessingActivity", graph.Properties{"name": "Orphan Activity", "status": "active"})
+ require.NoError(t, err)
+
+ createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows", `{
+ "properties":{"name":"HR -> Payroll","transferType":"internal","legalBasis":"contract"},
+ "fromActivityId":"`+string(src.ID)+`",
+ "toActivityId":"`+string(dst.ID)+`",
+ "toProcessorId":"`+string(proc.ID)+`",
+ "categoryIds":["`+string(cat.ID)+`"],
+ "legalBasisId":"`+string(basis.ID)+`"
+ }`)
+ require.Equal(t, http.StatusCreated, createResp.StatusCode)
+ created := decodeBody(t, createResp)
+ flowID, ok := created["id"].(string)
+ require.True(t, ok)
+ assert.Equal(t, "DataFlow", created["label"])
+ require.NotEmpty(t, created["properties"].(map[string]any)["createdAt"])
+
+ listResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows", "")
+ require.Equal(t, http.StatusOK, listResp.StatusCode)
+ listBody := decodeBody(t, listResp)
+ assert.Equal(t, float64(1), listBody["total"])
+ items := listBody["items"].([]any)
+ item := items[0].(map[string]any)
+ assert.Equal(t, "HR -> Payroll", item["properties"].(map[string]any)["name"])
+ assert.Contains(t, item["fromNames"], "HR Intake")
+ assert.Contains(t, item["toNames"], "Payroll")
+ assert.Contains(t, item["toNames"], "Vendor X")
+ assert.Contains(t, item["categoryNames"], "Employee Data")
+
+ detailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/"+flowID, "")
+ require.Equal(t, http.StatusOK, detailResp.StatusCode)
+ detailBody := decodeBody(t, detailResp)
+ assert.Len(t, detailBody["edges"], 6)
+
+ updateResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/gdpr/data-flows/"+flowID, `{"name":"HR -> Payroll v2","legalBasis":"contract"}`)
+ require.Equal(t, http.StatusOK, updateResp.StatusCode)
+ updateBody := decodeBody(t, updateResp)
+ assert.Equal(t, "HR -> Payroll v2", updateBody["properties"].(map[string]any)["name"])
+ require.NotEmpty(t, updateBody["properties"].(map[string]any)["updatedAt"])
+
+ mapResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/map", "")
+ require.Equal(t, http.StatusOK, mapResp.StatusCode)
+ mapBody := decodeBody(t, mapResp)
+ assert.NotEmpty(t, mapBody["nodes"])
+ assert.NotEmpty(t, mapBody["edges"])
+
+ validateResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows/validate", `{"inspectionId":"insp-1"}`)
+ require.Equal(t, http.StatusOK, validateResp.StatusCode)
+ validateBody := decodeBody(t, validateResp)
+ assert.Equal(t, float64(3), validateBody["total"])
+ assert.Equal(t, float64(1), validateBody["pass"])
+ assert.Equal(t, float64(2), validateBody["warnings"])
+ assert.Equal(t, float64(0), validateBody["fail"])
+
+ validations, err := g.NodesByLabel("DataFlowValidation")
+ require.NoError(t, err)
+ assert.Len(t, validations, 1)
+
+ deleteResp := complianceRequest(t, ts.URL, http.MethodDelete, "/api/v2/compliance/gdpr/data-flows/"+flowID, "")
+ require.Equal(t, http.StatusOK, deleteResp.StatusCode)
+ deleteBody := decodeBody(t, deleteResp)
+ assert.Equal(t, "deleted", deleteBody["status"])
+
+ missingResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/"+flowID, "")
+ require.Equal(t, http.StatusNotFound, missingResp.StatusCode)
+}
+
+func TestComplianceDataFlowErrors(t *testing.T) {
+ ts, _, _ := setupComplianceTestServer(t)
+
+ createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows", `{bad}`)
+ require.Equal(t, http.StatusBadRequest, createResp.StatusCode)
+ _ = decodeBody(t, createResp)
+
+ updateResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/gdpr/data-flows/", `{"name":"broken"}`)
+ require.Equal(t, http.StatusBadRequest, updateResp.StatusCode)
+ _ = decodeBody(t, updateResp)
+
+ deleteResp := complianceRequest(t, ts.URL, http.MethodDelete, "/api/v2/compliance/gdpr/data-flows/", "")
+ require.Equal(t, http.StatusBadRequest, deleteResp.StatusCode)
+ _ = decodeBody(t, deleteResp)
+}
diff --git a/internal/server/compliance_gdpr_test.go b/internal/server/compliance_gdpr_test.go
index 87fd164..f85bb8c 100644
--- a/internal/server/compliance_gdpr_test.go
+++ b/internal/server/compliance_gdpr_test.go
@@ -20,6 +20,8 @@ import (
"net/http"
"strings"
"testing"
+
+ "github.com/scalytics/kafgraph/internal/graph"
)
func TestGDPRSetupCRUD(t *testing.T) {
@@ -112,3 +114,88 @@ func TestGDPRDSRSLA(t *testing.T) {
t.Fatalf("expected 1 SLA item, got %v", total)
}
}
+
+func TestGDPRDetailUpdateAndDelete(t *testing.T) {
+ ts, _, g := setupComplianceTestServer(t)
+
+ lb, err := g.CreateNode("LegalBasis", graph.Properties{"name": "Contract"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sm, err := g.CreateNode("SecurityMeasure", graph.Properties{"name": "Encryption"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ cat, err := g.CreateNode("DataCategory", graph.Properties{"name": "Employee Data"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ropaBody := `{"name":"Analytics","purpose":"Insights","legalBasisId":"` + string(lb.ID) + `","securityMeasureId":"` + string(sm.ID) + `","categoryIds":["` + string(cat.ID) + `"]}`
+ resp, err := http.Post(ts.URL+"/api/v2/compliance/gdpr/ropa", "application/json", strings.NewReader(ropaBody))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ var created map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
+ t.Fatal(err)
+ }
+ ropaID, _ := created["id"].(string)
+ if ropaID == "" {
+ t.Fatal("expected ropa ID")
+ }
+
+ detailResp, err := http.Get(ts.URL + "/api/v2/compliance/gdpr/ropa/" + ropaID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = detailResp.Body.Close() }()
+ var detail map[string]any
+ if err := json.NewDecoder(detailResp.Body).Decode(&detail); err != nil {
+ t.Fatal(err)
+ }
+ if len(detail["edges"].([]any)) != 3 {
+ t.Fatalf("expected 3 edges, got %d", len(detail["edges"].([]any)))
+ }
+
+ req, _ := http.NewRequest("PUT", ts.URL+"/api/v2/compliance/gdpr/ropa/"+ropaID, strings.NewReader(`{"name":"Analytics v2"}`))
+ req.Header.Set("Content-Type", "application/json")
+ updateResp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = updateResp.Body.Close() }()
+ var updated map[string]any
+ if err := json.NewDecoder(updateResp.Body).Decode(&updated); err != nil {
+ t.Fatal(err)
+ }
+ props, _ := updated["properties"].(map[string]any)
+ if props["name"] != "Analytics v2" {
+ t.Fatalf("expected updated name, got %v", props["name"])
+ }
+
+ evidenceResp, err := http.Post(ts.URL+"/api/v2/compliance/gdpr/evidence", "application/json", strings.NewReader(`{"title":"Audit Trail"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = evidenceResp.Body.Close() }()
+ var evidence map[string]any
+ if err := json.NewDecoder(evidenceResp.Body).Decode(&evidence); err != nil {
+ t.Fatal(err)
+ }
+ evidenceID, _ := evidence["id"].(string)
+ if evidenceID == "" {
+ t.Fatal("expected evidence ID")
+ }
+
+ delReq, _ := http.NewRequest("DELETE", ts.URL+"/api/v2/compliance/gdpr/evidence/"+evidenceID, nil)
+ delResp, err := http.DefaultClient.Do(delReq)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = delResp.Body.Close() }()
+ if delResp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200 on delete, got %d", delResp.StatusCode)
+ }
+}
diff --git a/internal/server/compliance_inspection_test.go b/internal/server/compliance_inspection_test.go
new file mode 100644
index 0000000..a5ab8cd
--- /dev/null
+++ b/internal/server/compliance_inspection_test.go
@@ -0,0 +1,101 @@
+// Copyright 2026 Scalytics, Inc.
+// Copyright 2026 Mirko Kämpf
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/scalytics/kafgraph/internal/graph"
+)
+
+func TestComplianceInspectionCRUD(t *testing.T) {
+ ts, _, g := setupComplianceTestServer(t)
+
+ scope, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "Payroll", "status": "active"})
+ require.NoError(t, err)
+ scan, err := g.CreateNode("ComplianceScan", graph.Properties{"scanId": "scan-1"})
+ require.NoError(t, err)
+
+ createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections", `{
+ "properties":{"title":"Quarterly Review","inspectorId":"ins-1"},
+ "scopeNodeIds":["`+string(scope.ID)+`"],
+ "scanId":"scan-1"
+ }`)
+ require.Equal(t, http.StatusCreated, createResp.StatusCode)
+ inspection := decodeBody(t, createResp)
+ inspectionID := inspection["id"].(string)
+
+ listResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/inspections?status=draft", "")
+ require.Equal(t, http.StatusOK, listResp.StatusCode)
+ listBody := decodeBody(t, listResp)
+ assert.Equal(t, float64(1), listBody["total"])
+
+ findingResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections/"+inspectionID+"/findings", `{
+ "properties":{"title":"Missing evidence"},
+ "affectedNodeIds":["`+string(scope.ID)+`"]
+ }`)
+ require.Equal(t, http.StatusCreated, findingResp.StatusCode)
+ finding := decodeBody(t, findingResp)
+ findingID := finding["id"].(string)
+
+ remediationResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/findings/"+findingID+"/remediation", `{"title":"Attach evidence"}`)
+ require.Equal(t, http.StatusCreated, remediationResp.StatusCode)
+ remediation := decodeBody(t, remediationResp)
+ remediationID := remediation["id"].(string)
+
+ updateFindingResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/findings/"+findingID, `{"status":"remediated","title":"Resolved"}`)
+ require.Equal(t, http.StatusOK, updateFindingResp.StatusCode)
+ _ = decodeBody(t, updateFindingResp)
+
+ updateRemediationResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/remediation/"+remediationID, `{"status":"completed","verifiedBy":"lead-1"}`)
+ require.Equal(t, http.StatusOK, updateRemediationResp.StatusCode)
+ _ = decodeBody(t, updateRemediationResp)
+
+ detailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/inspections/"+inspectionID, "")
+ require.Equal(t, http.StatusOK, detailResp.StatusCode)
+ detail := decodeBody(t, detailResp)
+ assert.Len(t, detail["findings"], 1)
+ assert.Len(t, detail["scope"], 1)
+ assert.NotNil(t, detail["basedOnScan"])
+
+ findingDetailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/findings/"+findingID, "")
+ require.Equal(t, http.StatusOK, findingDetailResp.StatusCode)
+ findingDetail := decodeBody(t, findingDetailResp)
+ assert.Len(t, findingDetail["remediations"], 1)
+ assert.Len(t, findingDetail["affected"], 1)
+
+ updateInspectionResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/inspections/"+inspectionID, `{"status":"review"}`)
+ require.Equal(t, http.StatusOK, updateInspectionResp.StatusCode)
+ _ = decodeBody(t, updateInspectionResp)
+
+ signOffResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections/"+inspectionID+"/sign-off", `{"approverId":"lead-1"}`)
+ require.Equal(t, http.StatusOK, signOffResp.StatusCode)
+ signOffBody := decodeBody(t, signOffResp)
+ assert.Equal(t, "signed_off", signOffBody["status"])
+
+ eventsResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/events?limit=2", "")
+ require.Equal(t, http.StatusOK, eventsResp.StatusCode)
+ events := decodeBody(t, eventsResp)
+ assert.Len(t, events["events"], 2)
+ assert.True(t, events["total"].(float64) >= 2)
+
+ _, err = g.GetNode(scan.ID)
+ require.NoError(t, err)
+}
diff --git a/internal/server/compliance_test.go b/internal/server/compliance_test.go
index 13d19d4..6d1269f 100644
--- a/internal/server/compliance_test.go
+++ b/internal/server/compliance_test.go
@@ -114,6 +114,10 @@ func TestComplianceScan(t *testing.T) {
func TestComplianceDashboard(t *testing.T) {
ts, _, _ := setupComplianceTestServer(t)
+ _, err := http.Post(ts.URL+"/api/v2/compliance/scan", "application/json", strings.NewReader(`{"framework":"gdpr","module":"setup"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
resp, err := http.Get(ts.URL + "/api/v2/compliance/dashboard")
if err != nil {
t.Fatal(err)
@@ -122,4 +126,89 @@ func TestComplianceDashboard(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body["latestScan"] == nil {
+ t.Fatal("expected latestScan")
+ }
+ if body["moduleScores"] == nil {
+ t.Fatal("expected moduleScores")
+ }
+}
+
+func TestComplianceScanListingDetailAndScore(t *testing.T) {
+ ts, _, _ := setupComplianceTestServer(t)
+
+ resp, err := http.Post(ts.URL+"/api/v2/compliance/scan", "application/json", strings.NewReader(`{"framework":"gdpr","module":"setup"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var scan map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&scan); err != nil {
+ t.Fatal(err)
+ }
+ scanID, _ := scan["scanId"].(string)
+ if scanID == "" {
+ t.Fatal("expected scanId")
+ }
+
+ listResp, err := http.Get(ts.URL + "/api/v2/compliance/scans")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listResp.Body.Close() }()
+ var list map[string]any
+ if err := json.NewDecoder(listResp.Body).Decode(&list); err != nil {
+ t.Fatal(err)
+ }
+ if total, _ := list["total"].(float64); total < 1 {
+ t.Fatalf("expected at least one scan, got %v", total)
+ }
+
+ detailResp, err := http.Get(ts.URL + "/api/v2/compliance/scans/" + scanID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = detailResp.Body.Close() }()
+ var detail map[string]any
+ if err := json.NewDecoder(detailResp.Body).Decode(&detail); err != nil {
+ t.Fatal(err)
+ }
+ props, _ := detail["properties"].(map[string]any)
+ if props["scanId"] != scanID {
+ t.Fatalf("expected detail for %s, got %v", scanID, props["scanId"])
+ }
+ if _, ok := detail["evaluations"].([]any); !ok {
+ t.Fatal("expected evaluations array")
+ }
+
+ scoreResp, err := http.Get(ts.URL + "/api/v2/compliance/score")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = scoreResp.Body.Close() }()
+ var score map[string]any
+ if err := json.NewDecoder(scoreResp.Body).Decode(&score); err != nil {
+ t.Fatal(err)
+ }
+ scores, ok := score["scores"].([]any)
+ if !ok || len(scores) == 0 {
+ t.Fatal("expected score entries")
+ }
+}
+
+func TestModuleFromRuleID(t *testing.T) {
+ if got := moduleFromRuleID("GDPR-ROPA-001"); got != "ropa" {
+ t.Fatalf("expected ropa, got %s", got)
+ }
+ if got := moduleFromRuleID("BROKEN"); got != "unknown" {
+ t.Fatalf("expected unknown, got %s", got)
+ }
}
diff --git a/internal/server/http_test.go b/internal/server/http_test.go
index 94a2fa9..3d7b1ab 100644
--- a/internal/server/http_test.go
+++ b/internal/server/http_test.go
@@ -25,6 +25,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/scalytics/kafgraph/internal/cluster"
+ "github.com/scalytics/kafgraph/internal/compliance"
"github.com/scalytics/kafgraph/internal/config"
"github.com/scalytics/kafgraph/internal/graph"
)
@@ -173,3 +175,31 @@ func TestHTTPServerHandler(t *testing.T) {
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
+
+func TestServerOptionsAndReadJSON(t *testing.T) {
+ opts := &serverOpts{}
+ var exec cluster.QueryExecutor
+ var membership *cluster.Membership
+ var partMap *cluster.PartitionMap
+ var compEngine *compliance.Engine
+
+ WithExecutor(exec)(opts)
+ WithBrain(nil)(opts)
+ WithConfig(nil)(opts)
+ WithMembership(membership)(opts)
+ WithPartitionMap(partMap)(opts)
+ WithCompliance(compEngine)(opts)
+
+ assert.Nil(t, opts.exec)
+ assert.Nil(t, opts.brain)
+ assert.Nil(t, opts.cfg)
+ assert.Nil(t, opts.membership)
+ assert.Nil(t, opts.partMap)
+ assert.Nil(t, opts.compEngine)
+
+ req := httptest.NewRequest(http.MethodPost, "/unused", nil)
+ req.Body = nil
+ err := readJSON(req, &map[string]any{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "empty request body")
+}
diff --git a/internal/server/management.go b/internal/server/management.go
index 4b7cc7b..e00b9b5 100644
--- a/internal/server/management.go
+++ b/internal/server/management.go
@@ -451,17 +451,6 @@ func handleMgmtConfigDetailed(opts *serverOpts) http.HandlerFunc {
return
}
- // Define all settings grouped by section.
- type sectionDef struct {
- Name string
- Settings []struct {
- Key string
- Default any
- GetValue func(*config.Config) any
- }
- }
-
- // Build the detailed config by section.
sections := buildConfigSections(opts.cfg)
writeJSON(w, http.StatusOK, sections)
}