api: publish the detection catalog with its gaps

The catalog serves the rule set actually installed, not a document describing one,
so the coverage claim is the running configuration and cannot drift from it.

The published gap list is in the same response deliberately. A catalog that shows
only what is covered invites the reader to assume the rest is covered too, and the
reader here is a supervisor. Naming what is not detected, with the citation that
requires it, is the difference between a coverage claim and a coverage assertion.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 11:56:58 -07:00
co-authored by Hanzo Dev
parent 95c570b1df
commit 3ae6be949e
2 changed files with 55 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
import (
"net/http"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/pkg/rules"
)
// catalog reports what this deployment detects, what obliges it, and what it does
// not detect.
//
// It serves the rule set actually installed rather than a document describing one,
// so the coverage claim is the running configuration and cannot drift from it. The
// gap list is published in the same response for the same reason: a catalog that
// shows only what is covered invites the reader to assume the remainder is covered
// too, and the honest surface names both.
func (h *Handler) catalog() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if _, err := h.tenant(e); err != nil {
return refuse(e, err)
}
if h.Engine == nil {
return fail(e, http.StatusServiceUnavailable, "no engine is wired")
}
installed := h.Engine.Rules()
entries := make([]map[string]any, 0, len(installed))
for _, r := range installed {
entries = append(entries, map[string]any{
"id": r.ID,
"name": r.Name,
"typology": r.Typology,
"description": r.Description,
"expression": r.DSL,
"severity": r.Severity,
"action": r.Action,
"enabled": r.Enabled,
"citations": r.Citations,
})
}
return e.JSON(http.StatusOK, map[string]any{
"typologies": rules.Typologies(),
"rules": entries,
"obligations": rules.Obligations(),
"gaps": rules.Gaps(),
})
}
}
+1
View File
@@ -185,6 +185,7 @@ func (h *Handler) Register(se *core.ServeEvent) {
se.Router.POST("/v1/aml/relationships/search", h.searchRelationships())
se.Router.POST("/v1/aml/sanctions/search", h.searchSanctions())
se.Router.GET("/v1/aml/sanctions/sources", h.screeningSources())
se.Router.GET("/v1/aml/catalog", h.catalog())
se.Router.GET("/v1/aml/health", h.health())
}