Files
ARTEX/db/task_scope.go
BaaRai cd8a830b53 docs(coverage): say "declared domain or IP root" for auto-scope roots (F8)
declaredScopeFilter treats only root_domain, subdomain, ip and cidr rows as
roots. A declared company, icp or keyword row does not make an auto row
count, but the add_task_scope description said "a goal target or scope
added here or manually". The description and the code comments now say
"declared domain or IP root" and name the kinds that act as roots.
2026-11-12 09:38:52 +01:00

668 lines
22 KiB
Go

package db
import (
"database/sql"
"fmt"
"net"
"strconv"
"strings"
)
// TaskScope is one row of a task's test scope — coverage scope, not enforced: it is
// the coverage denominator and the filter for asset queries, and nothing blocks a
// tool call outside it. Rows come from insertAssets / intent asset_ids (source='auto',
// conservative, one per explicitly-inserted asset), from the add_task_scope tool
// (source='agent': the goal targets the decomposer registers, or planner widening),
// or from the operator (source='manual'). An auto row counts toward coverage only
// under a declared domain or IP root (a non-auto root_domain/subdomain/ip/cidr row),
// see declaredScopeFilter.
type TaskScope struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr|icp|keyword
CompanyID *int64 `json:"company_id,omitempty"`
// CompanyName is resolved for kind=company so callers can label a scope row
// without a second lookup. Empty when the row is not a company reference.
CompanyName string `json:"company_name,omitempty"`
Domain string `json:"domain,omitempty"`
Net string `json:"net,omitempty"`
Value string `json:"value,omitempty"`
Source string `json:"source"`
Reason string `json:"reason,omitempty"`
}
// stripHostPort drops a trailing :port from a host:port / ip:port / [ipv6]:port
// value, returning the bare host. Bare hosts, bare IPs (v4 or v6, whose colons make
// them ambiguous), and anything not in host:port form are returned unchanged. Scope
// is host/net based, so the port from a target like "10.0.188.136:3000" or
// "api.example.com:8080" is simply discarded rather than baked into the key.
func stripHostPort(v string) string {
v = strings.TrimSpace(v)
if host, _, err := net.SplitHostPort(v); err == nil {
return host
}
return v
}
// ipToHostCIDR turns a bare IP into its single-host CIDR (/32 or /128). "" if invalid.
func ipToHostCIDR(ip string) string {
ip = strings.TrimSpace(ip)
p := net.ParseIP(ip)
if p == nil {
return ""
}
if p.To4() != nil {
return ip + "/32"
}
return ip + "/128"
}
// upsertTaskScope inserts one scope row idempotently (uq_task_scope). A duplicate is
// silently ignored. taskID<=0 or empty kind → no-op.
func (s *AssetStore) upsertTaskScopeResult(ts TaskScope) (bool, error) {
if ts.TaskID <= 0 || ts.Kind == "" {
return false, nil
}
var domainVal, netVal, companyVal, valueVal any
if ts.Domain != "" {
domainVal = ts.Domain
}
if ts.Net != "" {
netVal = ts.Net
}
if ts.CompanyID != nil && *ts.CompanyID > 0 {
companyVal = *ts.CompanyID
}
if ts.Value != "" {
valueVal = ts.Value
}
src := ts.Source
if src == "" {
src = "auto"
}
query := `
INSERT INTO task_scope(task_id, kind, company_id, domain, net, value, source, reason)
VALUES ($1,$2,$3,$4,$5::cidr,$6,$7,NULLIF($8,''))
ON CONFLICT DO NOTHING`
var (
result sql.Result
err error
)
if s.tx != nil {
result, err = s.tx.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
} else {
result, err = s.db.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
}
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
return rows > 0, err
}
func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
_, err := s.upsertTaskScopeResult(ts)
return err
}
// AddAutoScope records the conservative task scope implied by ONE explicitly-inserted
// asset item (source='auto'). MUST be called only from insertAssets' top-level loop —
// never from a db-layer side effect (linkHostAssets), so derived assets do not blindly expand the scope.
// Rule: scope granularity follows the asset's own type. taskID<=0 → no-op.
// The asset comes from target-derived content, so the row counts toward coverage and
// list_untested_assets only under a declared domain or IP root (declaredScopeFilter).
func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error {
if taskID <= 0 {
return nil
}
switch assetType {
case "root_domain":
if d := DomainKey(domain); d != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "root_domain", Domain: d})
}
case "subdomain":
if d := DomainKey(domain); d != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: d})
}
case "service", "endpoint":
host := domain
if host == "" && rawURL != "" {
host, _, _ = parseURL(normalizeURL(rawURL))
}
host = DomainKey(host)
if host != "" && net.ParseIP(host) == nil {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: host})
}
// IP-literal host or no host → fall back to ip scope if we have one.
if c := ipToHostCIDR(ip); c != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
}
case "ip":
if c := ipToHostCIDR(ip); c != "" {
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
}
}
return nil
}
// AddAgentScope parses a (kind,value) pair and records it as task scope.
// source is "agent" when called from an LLM tool, "manual" from the UI.
// company: value = company name or id (must already exist). root_domain/subdomain:
// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128).
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) {
if source == "" {
source = "agent"
}
ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason}
if taskID <= 0 {
return ts, fmt.Errorf("task_id is required")
}
value = strings.TrimSpace(value)
if value == "" {
return ts, fmt.Errorf("value must not be empty")
}
switch kind {
case "company":
if s.company == nil {
return ts, fmt.Errorf("company store is not enabled")
}
var comp *Company
var err error
if id, e := strconv.ParseInt(value, 10, 64); e == nil {
comp, err = s.company.GetCompany(id)
} else {
comp, err = s.company.GetCompanyByName(value)
}
if err != nil {
return ts, err
}
if comp == nil {
return ts, fmt.Errorf("company does not exist: %s (confirm with list_companies first, or create the company)", value)
}
ts.CompanyID = &comp.ID
case "root_domain":
d := DomainKey(stripHostPort(value))
root, _ := RootDomain(d)
if root == "" {
root = d
}
if root == "" {
return ts, fmt.Errorf("invalid root domain: %s", value)
}
ts.Domain = root
case "subdomain":
d := DomainKey(stripHostPort(value))
if d == "" {
return ts, fmt.Errorf("invalid subdomain: %s", value)
}
ts.Domain = d
case "ip", "cidr":
v := value
if !strings.Contains(v, "/") {
v = ipToHostCIDR(stripHostPort(v))
ts.Kind = "ip"
} else {
ts.Kind = "cidr"
}
if v == "" {
return ts, fmt.Errorf("invalid ip/cidr: %s", value)
}
if _, _, err := net.ParseCIDR(v); err != nil {
return ts, fmt.Errorf("invalid ip/cidr: %s", value)
}
ts.Net = v
case "icp", "keyword":
parsed, err := ParseScopeInput(ScopeInput{Kind: kind, Value: value})
if err != nil {
return ts, err
}
ts.Value = parsed.Value
default:
return ts, fmt.Errorf("unsupported kind: %s (company/root_domain/subdomain/ip/cidr/icp/keyword)", kind)
}
if err := s.upsertTaskScope(ts); err != nil {
return ts, err
}
return ts, nil
}
// DeleteTaskScope removes a single scope row by id, scoped to the given task.
// Returns whether a row was actually deleted.
func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) {
res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ListTaskScope returns all scope rows for a task.
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
rows, err := s.db.Query(`
SELECT ts.id, ts.kind, COALESCE(ts.company_id,0), COALESCE(c.name,''), COALESCE(ts.domain,''),
COALESCE(ts.net::text,''), COALESCE(ts.value,''), ts.source, COALESCE(ts.reason,'')
FROM task_scope ts
LEFT JOIN companies c ON c.id=ts.company_id
WHERE ts.task_id=$1 ORDER BY ts.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TaskScope{}
for rows.Next() {
var t TaskScope
var cid int64
if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.CompanyName, &t.Domain, &t.Net, &t.Value, &t.Source, &t.Reason); err != nil {
return nil, err
}
t.TaskID = taskID
if cid > 0 {
t.CompanyID = &cid
}
out = append(out, t)
}
return out, rows.Err()
}
// CoverageAsset is one in-scope asset (used for the untested backlog sample).
type CoverageAsset struct {
ID int64 `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
}
// CoverageByType is per-asset-type coverage: total in scope vs tested.
type CoverageByType struct {
Type string `json:"type"`
Total int `json:"total"`
Tested int `json:"tested"`
}
// Coverage is a task's rough asset test coverage — a reference figure for the agent,
// NOT a precise metric. Denominator = assets matching a declared task_scope row or an
// auto row under a declared domain or IP root (declaredScopeFilter); Tested = those anchored to at
// least one fact node in the exploration.
type Coverage struct {
Enabled bool `json:"enabled"` // whether the asset coverage feature is on; when false the other fields are zero
ScopeRows int `json:"scope_rows"` // 0 → scope not anchored
Denominator int `json:"denominator"` // number of assets in scope
Tested int `json:"tested"` // tested (approximate)
Pct *float64 `json:"pct"` // coverage; null when the denominator is 0
ByType []CoverageByType `json:"by_type"` // total/tested per asset type
}
// CoverageEnabled reports whether a task has the asset-coverage feature turned on
// (tasks.coverage_enabled). Missing row / error → true (fail open to the default),
// so unknown/legacy tasks keep the historical behavior. taskID<=0 → true.
func (s *AssetStore) CoverageEnabled(taskID int64) bool {
if taskID <= 0 {
return true
}
var enabled bool
if err := s.db.QueryRow(`SELECT COALESCE(coverage_enabled,true) FROM tasks WHERE id=$1`, taskID).Scan(&enabled); err != nil {
return true
}
return enabled
}
// declaredScopeFilter keeps a task_scope row ts in the coverage match only when it is
// declared (source<>'auto': goal targets registered through add_task_scope, or manual
// scope) or when it is an auto row under a domain or IP root the same task declared:
// the same or a deeper domain of a declared root_domain/subdomain, or a net inside a
// declared ip/cidr. A declared company, icp or keyword row is not a root here. Auto
// rows come from target-derived content (insert_assets, intent asset_ids), so
// anything else stays out of the coverage denominator and list_untested_assets.
const declaredScopeFilter = `(ts.source <> 'auto' OR EXISTS (
SELECT 1 FROM task_scope d
WHERE d.task_id = ts.task_id AND d.source <> 'auto' AND (
(d.kind IN ('root_domain','subdomain') AND ts.domain IS NOT NULL
AND (ts.domain = d.domain OR right(ts.domain, length(d.domain)+1) = '.' || d.domain))
OR (d.kind IN ('ip','cidr') AND ts.net IS NOT NULL AND ts.net <<= d.net)
)
))`
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
const covTargetCTE = `
target AS (
SELECT DISTINCT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
FROM assets a
JOIN task_scope ts ON ts.task_id = $1 AND (
(ts.kind='company' AND a.company_id = ts.company_id)
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
OR (ts.kind='subdomain' AND a.domain = ts.domain)
OR (ts.kind IN ('ip','cidr') AND ts.net >>= try_inet(a.ip))
OR (ts.kind='icp' AND (
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
))
) AND ` + declaredScopeFilter + `
),
tested AS (
SELECT DISTINCT ea.asset_id
FROM exploration_anchors ea
JOIN exploration_nodes en ON en.id = ea.node_id
WHERE en.exploration_id = $2 AND en.kind = 'fact'
)`
// TaskCoverage computes rough per-type coverage for a task. taskID indexes
// task_scope + assets; expID indexes the fact anchors. Reference figure only.
func (s *AssetStore) TaskCoverage(taskID, expID int64) (*Coverage, error) {
cov := &Coverage{ByType: []CoverageByType{}}
_ = s.db.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1`, taskID).Scan(&cov.ScopeRows)
rows, err := s.db.Query(`WITH `+covTargetCTE+`
SELECT t.type, count(*) AS total,
count(*) FILTER (WHERE t.id IN (SELECT asset_id FROM tested)) AS tested
FROM target t GROUP BY t.type ORDER BY t.type`, taskID, expID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var bt CoverageByType
if err := rows.Scan(&bt.Type, &bt.Total, &bt.Tested); err != nil {
return nil, err
}
cov.ByType = append(cov.ByType, bt)
cov.Denominator += bt.Total
cov.Tested += bt.Tested
}
if err := rows.Err(); err != nil {
return nil, err
}
if cov.Denominator > 0 {
p := float64(cov.Tested) / float64(cov.Denominator)
cov.Pct = &p
}
return cov, nil
}
// ---------------------------------------------------------------------------
// Coverage graph — a force-directed view of a task's in-scope assets.
// ---------------------------------------------------------------------------
// CoverageGraphNode is one node of the asset coverage graph. Node identity is a
// string key: an asset row is "a:<id>", a company is "c:<id>", and a root domain
// with no asset row of its own is the synthetic "r:<domain>". Out-of-scope
// connector nodes (roots pulled in only to link subdomains, and the companies
// above them) carry InScope=false and are rendered gray.
type CoverageGraphNode struct {
Key string `json:"key"`
Kind string `json:"kind"` // company|root_domain|subdomain|ip|service|app|endpoint
Label string `json:"label"`
Tested bool `json:"tested"`
InScope bool `json:"in_scope"`
AssetID int64 `json:"asset_id,omitempty"` // 0 for company / synthetic root
CompanyID int64 `json:"company_id,omitempty"`
Domain string `json:"domain,omitempty"`
RootDomain string `json:"root_domain,omitempty"`
IP string `json:"ip,omitempty"`
URL string `json:"url,omitempty"`
Port int `json:"port,omitempty"`
ServiceType string `json:"service_type,omitempty"`
AppName string `json:"app_name,omitempty"`
PageTitle string `json:"page_title,omitempty"`
StatusCode int `json:"status_code,omitempty"`
}
// CoverageGraphEdge is a child→parent containment link (endpoint→service→
// subdomain/ip→root_domain→company, app→company).
type CoverageGraphEdge struct {
Src string `json:"src"`
Dst string `json:"dst"`
}
// CoverageGraphData is the whole graph for one task.
type CoverageGraphData struct {
Nodes []CoverageGraphNode `json:"nodes"`
Edges []CoverageGraphEdge `json:"edges"`
}
func assetKey(id int64) string { return "a:" + strconv.FormatInt(id, 10) }
func companyKey(id int64) string { return "c:" + strconv.FormatInt(id, 10) }
// hostPortOf returns the (host, port) a service / endpoint node hangs off — the
// domain if set, otherwise the URL host, otherwise the IP.
func hostPortOf(n *CoverageGraphNode) (string, int) {
host, port := n.Domain, n.Port
if host == "" && n.URL != "" {
h, p, _ := parseURL(normalizeURL(n.URL))
host = h
if port == 0 {
port = p
}
}
if host == "" {
host = n.IP
}
return host, port
}
// BuildCoverageGraph assembles the full coverage graph for a task and its direct
// read-only sources: every in-scope asset plus connector root domains/companies,
// with current-or-source fact anchors reflected in Tested. The legacy expID
// argument is retained for API compatibility; the task registry is authoritative.
func (s *AssetStore) BuildCoverageGraph(taskID, _ int64) (*CoverageGraphData, error) {
g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}}
if taskID <= 0 {
return g, nil
}
rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
SELECT a.id, a.type, COALESCE(a.company_id,0),
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0),
(a.id IN (SELECT asset_id FROM tested)) AS tested
FROM assets a JOIN target t ON t.id = a.id
ORDER BY a.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
byKey := map[string]*CoverageGraphNode{}
rootByDomain := map[string]string{} // root domain → node key
subByDomain := map[string]string{} // subdomain → node key
ipByAddr := map[string]string{} // ip literal → node key
svcByHostPort := map[string]string{}
svcByHost := map[string]string{}
companyIDs := map[int64]bool{} // referenced company ids (need a node)
add := func(n CoverageGraphNode) *CoverageGraphNode {
if _, ok := byKey[n.Key]; ok {
return byKey[n.Key]
}
g.Nodes = append(g.Nodes, n)
p := &g.Nodes[len(g.Nodes)-1]
byKey[n.Key] = p
return p
}
for rows.Next() {
var n CoverageGraphNode
var companyID int64
if err := rows.Scan(&n.AssetID, &n.Kind, &companyID,
&n.Domain, &n.RootDomain, &n.IP, &n.URL, &n.Port, &n.ServiceType,
&n.AppName, &n.PageTitle, &n.StatusCode, &n.Tested); err != nil {
return nil, err
}
n.Key = assetKey(n.AssetID)
n.CompanyID = companyID
n.InScope = true
n.Label = coverageNodeLabel(&n)
p := add(n)
switch n.Kind {
case "root_domain":
if n.Domain != "" {
rootByDomain[n.Domain] = p.Key
}
case "subdomain":
if n.Domain != "" {
subByDomain[n.Domain] = p.Key
}
case "ip":
if n.IP != "" {
ipByAddr[n.IP] = p.Key
}
case "service":
host, port := hostPortOf(p)
if host != "" {
svcByHost[host] = p.Key
svcByHostPort[host+"|"+strconv.Itoa(port)] = p.Key
}
}
if companyID > 0 {
companyIDs[companyID] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Connector root domains: any subdomain whose root domain is not itself an
// in-scope node. Pull the real asset row if one exists (so the drawer shows
// real detail), else synthesize a bare "r:<domain>" placeholder. Both gray.
missingRoots := map[string]bool{}
for _, n := range g.Nodes {
if n.Kind == "subdomain" && n.RootDomain != "" {
if _, ok := rootByDomain[n.RootDomain]; !ok {
missingRoots[n.RootDomain] = true
}
}
}
for root := range missingRoots {
var id, companyID int64
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
var node CoverageGraphNode
if err == nil && id > 0 {
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
CompanyID: companyID, Domain: root, Label: root}
if companyID > 0 {
companyIDs[companyID] = true
}
} else {
node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root}
}
add(node)
rootByDomain[root] = node.Key
}
// Company nodes for every referenced company id — always gray context.
for id := range companyIDs {
key := companyKey(id)
if _, ok := byKey[key]; ok {
continue
}
var name string
if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil {
continue
}
add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id,
Label: name, AssetID: 0})
}
// Derived containment edges (only when the parent node exists).
link := func(childKey, parentKey string) {
if parentKey == "" || parentKey == childKey {
return
}
if _, ok := byKey[parentKey]; !ok {
return
}
g.Edges = append(g.Edges, CoverageGraphEdge{Src: childKey, Dst: parentKey})
}
firstOf := func(keys ...string) string {
for _, k := range keys {
if k != "" {
if _, ok := byKey[k]; ok {
return k
}
}
}
return ""
}
for i := range g.Nodes {
n := &g.Nodes[i]
switch n.Kind {
case "subdomain":
link(n.Key, rootByDomain[n.RootDomain])
case "root_domain", "app", "ip":
if n.CompanyID > 0 {
link(n.Key, companyKey(n.CompanyID))
}
case "service":
link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain]))
case "endpoint":
host, port := hostPortOf(n)
parent := firstOf(
svcByHostPort[host+"|"+strconv.Itoa(port)], svcByHost[host],
subByDomain[host], subByDomain[n.Domain], ipByAddr[host], ipByAddr[n.IP],
rootByDomain[n.RootDomain])
link(n.Key, parent)
}
}
return g, nil
}
// coverageNodeLabel picks the human label for a coverage-graph node.
func coverageNodeLabel(n *CoverageGraphNode) string {
switch n.Kind {
case "endpoint", "service":
if n.URL != "" {
return n.URL
}
case "app":
if n.AppName != "" {
return n.AppName
}
}
for _, v := range []string{n.URL, n.Domain, n.IP, n.AppName, n.RootDomain} {
if v != "" {
return v
}
}
return n.Key
}
// ListUntestedAssets returns a task's in-scope, not-yet-tested assets, optionally
// filtered by asset type, paginated. Returns the page + the total count. limit<=0 → 10.
func (s *AssetStore) ListUntestedAssets(taskID, expID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) {
if limit <= 0 {
limit = 10
}
if offset < 0 {
offset = 0
}
typeFilter := ""
args := []any{taskID, expID}
if typ != "" {
typeFilter = " AND t.type = $3"
args = append(args, typ)
}
var total int
_ = s.db.QueryRow(`WITH `+covTargetCTE+`
SELECT count(*) FROM target t WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total)
pageArgs := append(append([]any{}, args...), limit, offset)
limPos := strconv.Itoa(len(args) + 1)
offPos := strconv.Itoa(len(args) + 2)
rows, err := s.db.Query(`WITH `+covTargetCTE+`
SELECT t.id, t.type, t.label FROM target t
WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+`
ORDER BY t.id LIMIT $`+limPos+` OFFSET $`+offPos, pageArgs...)
if err != nil {
return nil, total, err
}
defer rows.Close()
out := []CoverageAsset{}
for rows.Next() {
var a CoverageAsset
if err := rows.Scan(&a.ID, &a.Type, &a.Label); err != nil {
return nil, total, err
}
out = append(out, a)
}
return out, total, rows.Err()
}