search

package
v1.24.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package search owns the Harbor cross-cutting search primitive — the four runtime-side per-subsystem indexes (sessions, tasks, events, artifacts) plus the `search.query` palette dispatcher that aggregates them.

Why this package exists

The Console design split the cross-cutting global search into two halves: runtime-side for high-cardinality entities (sessions, tasks, events, artifacts) and Console-side for slow-moving catalog data (tools, agents, flows, MCP connections). This package owns the runtime-side half. Console-side adapters do NOT live here — they land in their per-page Console phases (73c/d/e/f/g/i/k).

The §4.4 seam

`Searcher` is the per-index interface; one Searcher instance per canonical index, all registered into a `SearcherRegistry` at boot. The `search.query` palette dispatcher (`aggregate.go::Query`) fans out concurrently to every requested index. There is no driver pluralism per index — the runtime owns one implementation per index; pluralism would be a post-V1 concern (an FTS sidecar swap, etc.).

Identity is mandatory (CLAUDE.md §6, RFC §5.5)

Every search call rejects requests with an incomplete identity triple via `ErrIdentityRequired`.

The identity axes of `SearchFilter` are SCOPED BOUNDARIES, not free predicates, and each axis is decided separately:

  • TENANT — an elided `TenantIDs` FOLDS to the caller's own tenant; naming another tenant, or naming more than one, requires an admin-tier claim (`ErrCrossTenantRequiresAdmin`).
  • USER — an elided `UserIDs` FOLDS to the caller's own user. It is NOT a wildcard: an omitted axis is the caller's own value, never "every user in the tenant". Naming another user, or naming more than one, requires the same admin-tier claim (`ErrCrossUserRequiresAdmin`).
  • SESSION — a single session id is used AS-IS with no claim, because a caller legitimately reads one of their OWN other sessions and the user fold above already decides whose rows are in play. A MULTI-value session set is a fan-in and takes the claim (`ErrCrossSessionRequiresAdmin`).

Under an admin-tier claim both widenings pass through untouched: a named foreign user reads that user, and an elided user fans across the effective tenant set rather than folding.

The `search.events` index narrows that last clause, and the narrowing is deliberate rather than an oversight: its replay filter is single-valued and its fan-in flag WRITES an admin-scope notice into the ring, so an elided user axis on a SAME-TENANT admin read keeps the caller's own scope instead of becoming an unrequested deployment-wide replay. It DOES fan when the same read already crosses tenants — there the caller's own user id names a principal of a different tenant, so folding would answer an empty page for a crossing the caller explicitly requested and was granted.

Every rejection is loud — there is no silent degradation to an empty result set, which would be indistinguishable from "that principal has no rows" (CLAUDE.md §13).

Heavy-payload bypass

Result rows whose underlying preview payload would exceed the heavy-content threshold ship as a populated `Ref` on the `SearchResultRow`, never inline bytes. Per-index searchers enforce this at the row-construction site.

Concurrent reuse

Every Searcher is a compiled artifact: the dependency reads are set once at construction (the SessionRegistry / TaskRegistry / Replayer / ArtifactStore / Redactor); per-call state lives in `ctx` + the `SearchRequest`. One Searcher serves N concurrent goroutines safely.

Index

Constants

View Source
const HeavyPreviewThreshold = 32 * 1024

HeavyPreviewThreshold is the source-record classification bound: a `SearchResultRow` whose REDACTED preview reaches this byte length is too large to be honestly represented by a snippet, so the row ships an empty `Preview` plus a `*SearchArtifactRef` and the caller reads the record itself.

It is NOT a context bound and NOT an offload bound, and it does not govern how many bytes ship: `PreviewMaxRunes` caps every emitted preview at 256 runes AFTER this check, so a row is either ~256 runes or a reference, at any threshold. It is Protocol-visible — it selects which arm of `SearchResultRow` is populated — so it carries its own literal at the value it has always had rather than tracking the LLM-context heavy-output threshold it used to alias. That threshold answers a different question (how many bytes may enter a prompt) and has since moved; this bound did not follow it.

View Source
const PerIndexTimeout = 5 * time.Second

PerIndexTimeout caps the per-index fan-out wait inside Query. A per-index Searcher whose Search has not returned within this budget is cancelled and its rows are dropped from the merge. V1 ships a generous 5-second cap; post-V1 may make this configurable per deployment.

View Source
const PreviewMaxRunes = 256

PreviewMaxRunes is the soft cap applied at the row-construction site after redaction — a preview that fits under HeavyPreviewThreshold but exceeds this rune count is truncated with an ellipsis. Keeps the wire response compact even when individual previews are within the hard byte bound.

Variables

View Source
var (
	// ErrIdentityRequired — the search request's identity scope is
	// missing one of (tenant, user, session). Identity is mandatory
	// (CLAUDE.md §6 rule 9, RFC §5.5).
	ErrIdentityRequired = errors.New("search: identity required (tenant/user/session)")
	// ErrCrossTenantRequiresAdmin — the request's filter expands the
	// query OUTSIDE the caller's authenticated tenant AND the caller
	// does not hold the `auth.ScopeAdmin` claim.
	ErrCrossTenantRequiresAdmin = errors.New("search: cross-tenant search requires the auth.ScopeAdmin claim")
	// ErrCrossUserRequiresAdmin — the request's filter expands the query
	// OUTSIDE the caller's own user, either by naming another user or by
	// naming more than one. Distinct from the cross-tenant sentinel so a
	// refused caller learns WHICH axis refused them; it maps to the same
	// wire code, so no client gains a code to branch on.
	ErrCrossUserRequiresAdmin = errors.New(
		"search: cross-user search requires the auth.ScopeAdmin or auth.ScopeConsoleFleet claim")
	// ErrCrossSessionRequiresAdmin — the request's filter names MORE THAN
	// ONE session. A single session id is never refused (a caller reads
	// their own other sessions freely); a multi-value set is the same
	// fan-in that elevates on the tenant and user axes.
	ErrCrossSessionRequiresAdmin = errors.New(
		"search: a multi-session search requires the auth.ScopeAdmin or auth.ScopeConsoleFleet claim")
	// ErrInvalidRequest — the request fails structural validation
	// (PageSize > MaxSearchPageSize, an unknown SearchIndex in
	// `Indexes`, etc.). Surfaces at the handler boundary as
	// CodeInvalidRequest.
	ErrInvalidRequest = errors.New("search: invalid request")
	// ErrRedactionFailed — a `SearchResultRow.Preview` failed
	// redaction. Per the CLAUDE.md §7 rule, the request fails loud
	// rather than emitting un-redacted bytes.
	ErrRedactionFailed = errors.New("search: redaction failed")
	// ErrUnknownIndex — an `Indexes` entry on a `SearchRequest` for
	// `search.query` is not one of the four canonical runtime-side
	// indexes. Distinct from ErrInvalidRequest so callers can branch.
	ErrUnknownIndex = errors.New("search: unknown index")
)

Sentinel errors. Callers compare via errors.Is.

View Source
var ConcurrentReuseTag = struct{ Note string }{Note: "search: per-index Searchers are compiled artifacts under the concurrent-reuse contract; per-call state lives in ctx + req"}

ConcurrentReuseTag is a no-op assertion site — kept as a package-level var so a future static analyser can grep it as the canonical concurrent-reuse witness across the search subsystem. The actual enforcement is the concurrent_reuse_test.go N≥100 stress.

Functions

func CrossSessionFanInRequested added in v1.24.0

func CrossSessionFanInRequested(req types.SearchRequest) bool

CrossSessionFanInRequested reports whether the request's filter names MORE THAN ONE session. The session axis is deliberately NOT gated on a single foreign value — a caller reads their own other sessions freely, and the user fold above the session axis already decides whose rows are in play — so only the multi-value fan-in elevates.

func CrossTenantRequested

func CrossTenantRequested(callerTenant string, req types.SearchRequest) bool

CrossTenantRequested reports whether the request's filter targets tenants OTHER than the caller's authenticated tenant. The handler gates this on the admin-scope predicate; CrossTenantRequested itself is a pure read.

The rule:

  • Empty Filter.TenantIDs → caller's own tenant only (no cross-tenant).
  • Filter.TenantIDs contains exactly the caller's tenant → no cross-tenant.
  • Any other shape → cross-tenant requested.

func CrossUserRequested added in v1.24.0

func CrossUserRequested(callerUser string, req types.SearchRequest) bool

CrossUserRequested reports whether the request's filter targets users OTHER than the caller's authenticated user. The caller gates this on the admin-scope predicate; CrossUserRequested itself is a pure read.

The rule is the USER twin of CrossTenantRequested, and it reproduces the three-branch semantics the event subsystem's wire-filter conversion already holds so the two agree by construction:

  • Empty Filter.UserIDs → caller's own user only (no cross-user).
  • Filter.UserIDs contains exactly the caller's user → no cross-user.
  • Any other name → cross-user requested.
  • len(Filter.UserIDs) > 1 → cross-user requested, EVEN when every entry is the caller repeated: asking for many principals' rows in one read is the fan-in trigger every axis shares.

func EffectiveTenantSet

func EffectiveTenantSet(callerTenant string, req types.SearchRequest) []string

EffectiveTenantSet returns the set of tenants the request should be scoped to AFTER admin-gating. When the caller's filter is empty, the effective set is `{callerTenant}` (the default). When the filter names tenants, the effective set is those tenants verbatim — the caller already passed the admin-scope gate (if needed) by the time this is called.

func EffectiveUserSet added in v1.24.0

func EffectiveUserSet(callerUser string, req types.SearchRequest) []string

EffectiveUserSet returns the set of users the request should be scoped to AFTER admin-gating, for a caller whose request was NOT widened. When the caller's filter is empty, the effective set is `{callerUser}` — the FOLD. An elided user axis is the caller's own value, never a wildcard: treating it as a wildcard is what let the default request with no caller input at all enumerate every user in the tenant. When the filter names users, the effective set is those users deduplicated and sorted.

Mirrors EffectiveTenantSet field for field, including its empty-string skip and its fall-back to the caller when every entry was empty. A widened (admin-tier) read reads WidenedUserSet instead, so that its elided axis is not folded.

func MatchesAnyField

func MatchesAnyField(needle string, fields ...string) bool

MatchesAnyField is a convenience over MatchesQuery — true when at least one of `fields` contains `needle`. Per-index Searchers call this to OR together the searchable fields (e.g. session ID + agent name + status).

func MatchesQuery

func MatchesQuery(haystack, needle string) bool

MatchesQuery reports whether haystack contains needle, case-folded. Empty needle matches anything (the caller wants every row in scope). Used uniformly by every per-index Searcher so the substring semantics are identical across the cluster.

func Paginate

func Paginate(all []types.SearchResultRow, req types.SearchRequest) (page, pageSize, pageCount int, totalCount int64, hasMore bool, slice []types.SearchResultRow)

Paginate slices rows according to the request's (defaulted) page + size and returns the slice plus the pagination math (page, page_size, page_count, total_count, has_more). Returns a non-nil empty slice when the page is past the end (rather than nil) so the JSON wire form is `[]` consistently.

func Query

Query is the `search.query` palette dispatcher — the runtime-side half of the Console design's "single search box in the Console header" experience. It concurrently fans out to every requested runtime-side index in the registry, merges the result rows, applies the union pagination, and returns one paginated `SearchResponse`.

Contract:

  • Identity is mandatory; missing-triple returns `ErrIdentityRequired`.
  • Identity-axis gating runs at the aggregate edge, in axis order (tenant, then user, then the session fan-in). A widening without the admin-tier claim is rejected with `ErrCrossTenantRequiresAdmin` / `ErrCrossUserRequiresAdmin` / `ErrCrossSessionRequiresAdmin` (NEVER silently downgraded to an empty page). The gate fires HERE as well as inside each Searcher because `Query` rewrites the sub-request before fan-out: one edge check is one place to get right instead of five places to forget.
  • Empty `req.Indexes` means "all four registered indexes."
  • Unknown indexes in `req.Indexes` are rejected at validation (`ErrUnknownIndex`); they NEVER fall through to a silent skip.
  • Per-index failures fan-in: a single index's failure does NOT fail the whole `search.query` — the dispatcher emits a best-effort union (per the design's "graceful degradation on backend stutter"). The failure mode is logged via the redactor's logger contract elsewhere; for the wire response the other indexes' rows still ship. This is the ONE exception to the fail-loud rule §13 carves out for aggregators — and only after identity + scope checks have already passed (those failures stay loud).
  • Carries NO index of its own; emits NO events.

`Query` is a pure function over the registry, ctx, callerID, and req — no per-call state lives on `*SearcherRegistry`.

func RedactAndCapPreview

func RedactAndCapPreview(ctx context.Context, redactor audit.Redactor, preview string) (out string, heavy bool, err error)

RedactAndCapPreview is the standard preview-emission helper every Searcher uses: it (a) runs the raw preview through the audit Redactor; (b) checks the byte-length against HeavyPreviewThreshold — when over, returns the empty preview + a true bool signalling the caller MUST populate a Ref instead; (c) caps to PreviewMaxRunes with an ellipsis.

On redaction failure: returns wrapped ErrRedactionFailed. The caller (typically inside Search) MUST NOT emit a row when this errors.

func SortRowsByOccurredAtDesc

func SortRowsByOccurredAtDesc(rows []types.SearchResultRow)

SortRowsByOccurredAtDesc orders rows newest-first. V1's ordering contract per the phase plan: lexicographic match + time-order; post-V1 may add relevance scoring.

func TimeInWindow

func TimeInWindow(t time.Time, req types.SearchRequest) bool

TimeInWindow reports whether t falls within the request's [Since, Until] window. Zero Since means "no lower bound"; zero Until means "no upper bound." When both are zero, every time passes.

func ValidateRequest

func ValidateRequest(callerID identity.Identity, req types.SearchRequest) error

ValidateRequest is the request-edge structural check shared by every `search.*` method. It enforces:

  • Identity triple present (`ErrIdentityRequired`).
  • PageSize within `[0, types.MaxSearchPageSize]` (`ErrInvalidRequest`).
  • Page non-negative (`ErrInvalidRequest`).
  • Every value in `Indexes` (when populated, for `search.query`) is a canonical index (`ErrUnknownIndex`).

The caller is the per-method handler (or the aggregate dispatcher); the result is normalised `(page, size)` defaults for the caller to apply.

func WidenedUserSet added in v1.24.0

func WidenedUserSet(req types.SearchRequest) []string

WidenedUserSet returns the user axis for a request whose widening an admin-tier claim already authorised. It does NOT fold: an elided axis returns nil, which every caller reads as "every user inside the effective tenant set" and passes to storage as the wildcard the store documents. A widened read fans in rather than folding its elided axes; folding here would silently turn a fleet view into a self view.

A named axis returns the same deduplicated, sorted set EffectiveUserSet returns, so the two differ on exactly one input.

Types

type Deps

type Deps struct {
	// Redactor is the audit redactor every Preview goes through before
	// emission. Required.
	Redactor audit.Redactor
	// AdminScope is the predicate the searcher consults to gate
	// cross-tenant requests. Required.
	AdminScope ScopeChecker
}

Deps bundles the construction-time dependencies every per-index searcher needs. Fields documented per searcher; missing required dependencies fail loud at construction (CLAUDE.md §5).

func (Deps) Validate

func (d Deps) Validate() error

Validate returns wrapped ErrInvalidRequest when a required Dep is missing.

type ScopeChecker

type ScopeChecker func(ctx context.Context) bool

ScopeChecker is the narrow predicate the Searchers consult to decide whether a cross-tenant request is allowed. The production implementation is `server.SearchAdminScopeFromAuth` — owned by the Runtime's network surface per the direction rule (runtime packages import protocol TYPES only, never protocol auth / behaviour), so this package never sees the Protocol's auth vocabulary; tests inject a deterministic predicate.

The signature deliberately takes `ctx` (not a verified-identity struct) so the implementation can read the verified scope set from the auth context attached by the middleware.

type Searcher

type Searcher interface {
	// Index returns the canonical index this Searcher serves.
	Index() types.SearchIndex
	// Search runs the query against the index, applies the identity +
	// scope filter, redacts each preview, paginates, and returns.
	Search(ctx context.Context, req types.SearchRequest) (types.SearchResponse, error)
}

Searcher is the §4.4 seam interface — one implementation per canonical runtime-side index. Each implementation is a concurrency-safe compiled artifact: every dependency is set once at construction; per-call state lives in `ctx` + `SearchRequest`.

Implementations MUST:

  • Reject incomplete identity (`ErrIdentityRequired`).
  • Gate cross-tenant requests on the admin-tier claim via the `ScopeChecker` passed at construction (`ErrCrossTenantRequiresAdmin`).
  • Gate cross-user requests and multi-session fan-ins on the SAME predicate (`ErrCrossUserRequiresAdmin`, `ErrCrossSessionRequiresAdmin`), and scope storage to `EffectiveUserSet` / `WidenedUserSet` rather than to the raw filter — the raw filter's elided user axis is a store wildcard.
  • Redact every emitted `Preview` via the supplied `audit.Redactor` before returning (`ErrRedactionFailed` on failure).
  • Ship a `*SearchArtifactRef` instead of inline bytes when a preview would exceed `HeavyPreviewThreshold`.
  • Honor `ctx.Err()` between long phases of work.

type SearcherRegistry

type SearcherRegistry struct {
	// contains filtered or unexported fields
}

SearcherRegistry is the registered set of per-index Searchers — one per canonical index. The aggregate dispatcher (`Query`) reads from it; the per-method protocol handlers route directly to the named Searcher via `Get`.

A registry is built once at boot (`NewRegistry`) and shared across every search call. Registration is closed after construction — there is no Register-at-runtime seam (the canonical index set is closed).

func NewRegistry

func NewRegistry(searchers ...Searcher) (*SearcherRegistry, error)

NewRegistry builds a SearcherRegistry from the given Searchers. Each supplied Searcher MUST report a canonical Index; duplicate indexes fail loud (the wiring is wrong). A registry MAY be incomplete (a subset of the four indexes) — `Query` skips unregistered indexes gracefully rather than failing the whole request, so a partial deployment is acceptable; the missing index simply contributes zero rows.

func (*SearcherRegistry) Get

Get returns the Searcher registered for the given index, or false when no Searcher is registered for that index.

func (*SearcherRegistry) Indexes

func (r *SearcherRegistry) Indexes() []types.SearchIndex

Indexes returns the sorted list of indexes the registry knows about. Deterministic — sorted lexicographically.

Directories

Path Synopsis
Package artifacts implements the `search.artifacts` runtime-side index — a server-enforced search over the artifact store's catalog, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package artifacts implements the `search.artifacts` runtime-side index — a server-enforced search over the artifact store's catalog, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package events implements the `search.events` runtime-side index — a server-enforced search over the event bus's replay ring, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package events implements the `search.events` runtime-side index — a server-enforced search over the event bus's replay ring, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package sessions implements the `search.sessions` runtime-side index — a server-enforced search over session lifecycle records, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package sessions implements the `search.sessions` runtime-side index — a server-enforced search over session lifecycle records, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package tasks implements the `search.tasks` runtime-side index — a server-enforced search over task lifecycle records, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.
Package tasks implements the `search.tasks` runtime-side index — a server-enforced search over task lifecycle records, scoped to the caller's own tenant AND own user unless an admin-tier claim widens it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL