catalog

package module
v0.3.4 Latest Latest
Warning

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

Go to latest
Published: Aug 21, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

Nicos Catalog

Go Reference CI Go Report Card License

Nicos Catalog is a local software-catalog engine and read-only Explorer. It models repositories, services, products, documents, and their relationships. A host supplies the folder layout and data providers. The engine validates, indexes, searches, graphs, and checks the catalog for drift.

Explorer gives people and agents the same bounded catalog view. It runs from the Go binary and needs no Node runtime. A static export uses the closed public projection and can be hosted without a catalog daemon.

The public core leaves out personal telemetry, business valuation, private query text, runtime credentials, and host-only portfolio policy. Those stay in host adapters.

Showcase

Nicos Catalog application icon

Nicos Catalog Explorer overview with synthetic entities

Nicos Catalog running inside Catalog Gallery as an independent synthetic host

The Explorer and Gallery images use synthetic fixtures. A CLI walk of the same demo is in screenshots/.

Install

Requires Go 1.24 or newer.

go install github.com/nstranquist/nicos-catalog/cmd/nicos-catalog@v0.3.4
nicos-catalog version --expect v0.3.4

For a source checkout:

go test ./...
go install ./cmd/nicos-catalog

Usage

Command What it does
nicos-catalog init Write missing starter files for a new corpus
nicos-catalog validate Check records against the engine schema
nicos-catalog reindex Rebuild the local index
nicos-catalog search BM25 search over the index
nicos-catalog graph Print the relationship graph
nicos-catalog drift Fail closed when files and the index disagree
nicos-catalog project Emit the closed public projection
nicos-catalog demo / demo --ui Synthetic Explorer; no personal data
nicos-catalog serve Read-only Explorer for an authored corpus
nicos-catalog export explorer Static public site from a public projection
nicos-catalog mcp --stdio Read-only MCP tools

--json is accepted on the verbs that print records. See the sections below for the exact flags.

Five-minute Explorer

The built-in demo contains synthetic entities only and writes to a temporary directory. The command removes that directory when the server stops.

Open the hosted synthetic Explorer to inspect the same public, read-only experience without installing the CLI. The hosted site contains no authored user corpus and sends no application telemetry.

nicos-catalog demo --ui --open

Explorer listens on a random loopback port. Press Ctrl-C to stop it. Use demo --ui without --open when a browser must not open automatically.

The JSON and terminal demo remain available:

nicos-catalog demo
nicos-catalog --json demo --query "developer platform"

Start an authored catalog

Run these commands in an empty project directory:

nicos-catalog init --template sample
nicos-catalog validate
nicos-catalog reindex
nicos-catalog serve --open

init writes only missing starter files. serve is read-only and accepts a loopback address only.

To exercise an authored corpus from this repository:

nicos-catalog --root . --corpus demo/catalog validate
nicos-catalog --root . --corpus demo/catalog reindex
nicos-catalog --root . --corpus demo/catalog search --limit 3 "ownership graph"
nicos-catalog --root . --corpus demo/catalog graph
nicos-catalog --root . --corpus demo/catalog drift
nicos-catalog --root . --corpus demo/catalog --json project --visibility public --allow-hosts example.com

Export a static public Explorer

Reindex the corpus before an export. Then select the public visibility boundary explicitly:

nicos-catalog reindex
nicos-catalog export explorer --out ./public-catalog --visibility public --allow-hosts example.com

The command writes one deterministic site. It rejects an unsafe output path, a symlink path, and a non-Explorer directory. The export contains projected entities only. See the static export guide.

Connect an agent

Start the read-only stdio MCP server after you build the index:

nicos-catalog mcp --stdio

The server exposes bounded search, page, graph, and health tools. It has no write tool and sends no telemetry. See the MCP guide.

GitHub-local collation is a host command, off until <config>/settings.yaml names a profile and sets github.collation.enabled: true. It walks local clones only; registered repos emit records, and --apply rebuilds the derived index without mutating those clones. With collation on, reindex keeps those records instead of wiping them. Settings can bound the walk (max_repos, skip_dir_names). --apply writes a snapshot; --from-snapshot reads it without walking. --profile-repos fills the missing-clone bucket. Factory enrollment gaps are observe-only (--enroll-manifest / ndev catalog external gaps).

nicos-catalog --json collate
nicos-catalog --json collate --apply

Host contract

layout, _ := (catalog.Layout{
    CorpusDir: "catalog",
    ConfigDir: ".catalog",
    CacheDir: ".catalog/cache",
    SidecarDataDir: ".catalog/sidecars",
}).Resolve(hostRoot)

engine, _ := catalog.New(layout, catalog.WithProviders(myProvider))
_, _ = engine.Reindex(ctx)
results, _ := engine.Search(ctx, "ownership graph", catalog.SearchOptions{Limit: 5})

A provider implements one small interface:

type Provider interface {
    Name() string
    Provide(context.Context, catalog.Layout) ([]catalog.Record, error)
}

FilesystemProvider handles YAML, JSON, and Markdown with YAML frontmatter. Its Strict mode rejects unknown fields, malformed frontmatter, trailing documents, and records without IDs; the CLI enables strict mode. It skips generated/cache directories plus _archive by default; hosts can add directory names through ExcludeDirs. StaticProvider supports embedded fixtures and API-backed hosts. Provider output is normalized and sorted. Duplicate IDs are rejected, even across providers.

Privacy boundary

ProjectPublic produces a closed PublicEntity export. It cannot encode source paths, annotations, owner fields, telemetry, query text, valuation, or sidecar data. Hosts may further restrict visibility, kinds, tags, URL hosts, and summary length. Publication should use this export rather than filtering the private index after serialization.

Why this exists

Large personal and organizational ecosystems become hard to reason about long before they become large enough to justify a heavyweight service catalog. Nicos Catalog keeps the data portable and reviewable while still providing the pieces that make a catalog operational: provider boundaries, deterministic derived state, search, typed relationships, and drift enforcement.

See the Explorer guide, the hosting guide, the performance guide, docs/architecture.md, and SECURITY.md.

Support and contributions

Read SUPPORT.md for the support, contribution, privacy, and security-reporting boundaries. This repository does not use GitHub Issues for support, feedback, or backlog management. Source and documentation fixes use reviewed pull requests.

Release state

v0.3.4 is the current module release. Read the release notes. Dated publication reviews record external release and deployment evidence without claiming launch or adoption.

A public module release proves distribution. It does not prove a hosted deployment, an independent adoption, or revenue. Maintainers must use the release runbook for future versions.

License

Apache-2.0.

Migrating from v0.1.x

v0.1.x v0.2.0
catalog.New(layout, p1, p2) catalog.New(layout, catalog.WithProviders(p1, p2))
engine.LoadIndex() engine.LoadIndex(ctx)
engine.Search(q, opts) engine.Search(ctx, q, opts)
catalog.ProjectPublic(index, policy) catalog.ProjectPublic(ctx, index, policy)
engine.Reconcile(ctx, true) engine.Reconcile(ctx, catalog.ReconcileApply)
catalog.Version catalog.Version()
Visibility: "public" Visibility: catalog.VisibilityPublic
report.Warnings []string report.Warnings []catalog.ValidationIssue

Index.SchemaVersion advanced to 2, so the first v0.2.0 run rebuilds any existing index. Drift reports index_schema_mismatch instead of failing, so this surfaces as a reindex prompt rather than an error. Record.Digest is now per-entity; the previous whole-payload value is Record.SourceDigest.

Errors are now typed. Match sentinels with errors.Is and recover detail with errors.As rather than comparing message text:

if errors.Is(err, catalog.ErrIndexMissing) { /* run reindex */ }

var policy *catalog.PolicyError
if errors.As(err, &policy) {
    log.Printf("entity %s field %s violated %s", policy.EntityID, policy.Field, policy.Rule)
}

See docs/api-stability.md for the versioning policy.

Documentation

Overview

Package catalog is a local software-catalog engine.

A host supplies the folder layout and data plugins. The engine validates records, indexes them, searches them with BM25, builds a relationship graph, and fails a drift check when the catalog no longer matches the source files. The public export format omits private data. The engine does not assume a repository layout, home directory, or business model.

Pipeline

Data moves through five stages, each of which is separately testable:

authored facts        files, APIs, or embedded fixtures
  → provider records  Provider.Provide
  → normalized set    Engine.Discover — trimmed, validated, ordered, deduped
  → derived index     Engine.Reindex — byte-deterministic, cached
  → public projection ProjectPublic — closed DTO, safe to publish

Host contract

A host supplies a Layout and one or more Providers:

layout, err := catalog.DefaultLayout(root).Resolve(root)
engine, err := catalog.New(layout, catalog.WithProviders(myProvider))
report, err := engine.Reindex(ctx)
results, err := engine.Search(ctx, "ownership graph", catalog.SearchOptions{Limit: 5})

Provider is deliberately small: a name and a Provide method. FilesystemProvider reads Markdown with YAML frontmatter, YAML, and JSON. StaticProvider serves embedded fixtures and API-backed hosts. Provider output is normalized and ordered by the engine, and duplicate ids fail closed across provider boundaries rather than shadowing one another.

Privacy boundary

ProjectPublic produces a PublicEntity, which is a closed DTO: it is structurally incapable of representing source paths, host annotations, owner telemetry, sidecar data, valuation, or query text. That is a property of the type, not of a filter — the field set is frozen by a reflection test that also rejects maps, interfaces, pointers, and embedded structs anywhere in the reachable type graph.

Publication should consume this DTO rather than filtering a private index after serialization. Two rules are easy to miss:

  • ProjectionPolicy.AllowHosts must be non-empty whenever any projected entity declares a PublicURL. An empty allowlist is a hard error, not an implicit permit.
  • Rejections never reproduce the value that was rejected. A PolicyError names the entity, the field, and the rule, because the error itself travels to logs and CI output.

Hosts building their own publication gates should call ScanPublicText rather than reimplementing the patterns, so host and library cannot drift apart.

Determinism

The index omits wall-clock time and sorts every collection, so identical inputs produce byte-identical output. Hosts are expected to depend on this: the usual pattern is to commit a generated artifact and fail a build when a fresh compile does not match it. Search scores are relative within one result set and are not comparable across queries or engines.

Errors

Sentinels are matched with errors.Is; structured detail is recovered with errors.As:

if errors.Is(err, catalog.ErrIndexMissing) { /* run reindex */ }

var policy *catalog.PolicyError
if errors.As(err, &policy) {
    log.Printf("entity %s field %s violated %s", policy.EntityID, policy.Field, policy.Rule)
}

Stability

The Go API follows SemVer. SchemaVersion is a separate contract governing the on-disk index; when it advances, Drift reports index_schema_mismatch so an upgrade prompts a reindex rather than failing. See docs/api-stability.md.

Example

The engine compiles authored facts into a deterministic index, then answers queries and publishes a closed projection from it.

package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

// exampleEngine builds an engine over a temporary host root.
func exampleEngine() (*catalog.Engine, func()) {
	root, err := os.MkdirTemp("", "nicos-catalog-example-")
	if err != nil {
		panic(err)
	}
	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(
		catalog.StaticProvider{ProviderName: "example", Entities: exampleEntities()},
	))
	if err != nil {
		panic(err)
	}
	return engine, func() { _ = os.RemoveAll(root) }
}

func main() {
	engine, cleanup := exampleEngine()
	defer cleanup()
	ctx := context.Background()

	report, err := engine.Reindex(ctx)
	if err != nil {
		panic(err)
	}
	fmt.Println("indexed:", report.EntityCount)

	results, err := engine.Search(ctx, "ownership graph", catalog.SearchOptions{Limit: 1})
	if err != nil {
		panic(err)
	}
	fmt.Println("top match:", results[0].Entity.ID)

	index, err := engine.LoadIndex(ctx)
	if err != nil {
		panic(err)
	}
	projection, err := catalog.ProjectPublic(ctx, index, catalog.ProjectionPolicy{})
	if err != nil {
		panic(err)
	}
	fmt.Println("published:", len(projection.Items))
}
Output:
indexed: 3
top match: system.orchard
published: 2

Index

Examples

Constants

View Source
const SchemaVersion = 2

SchemaVersion is the on-disk contract version of the derived index.

Version 2 changed Record.Digest from a whole-payload digest to a per-entity digest and introduced Record.SourceDigest, so a version 1 index cannot be compared against a version 2 discovery. Drift reports the difference as index_schema_mismatch rather than failing, which makes an engine upgrade a reindex prompt instead of a crash.

Variables

View Source
var (
	ErrInvalidLayout         = errors.New("catalog: invalid layout")
	ErrInvalidProvider       = errors.New("catalog: invalid provider")
	ErrProviderFailed        = errors.New("catalog: provider failed")
	ErrInvalidEntity         = errors.New("catalog: invalid entity")
	ErrDuplicateEntityID     = errors.New("catalog: duplicate entity id")
	ErrDecode                = errors.New("catalog: decode failed")
	ErrCorpusEscape          = errors.New("catalog: corpus path escapes the corpus root")
	ErrIndexMissing          = errors.New("catalog: index missing")
	ErrIndexSchema           = errors.New("catalog: unsupported index schema")
	ErrIndexCorrupt          = errors.New("catalog: index is corrupt")
	ErrEmptyQuery            = errors.New("catalog: query has no usable terms")
	ErrPolicyViolation       = errors.New("catalog: projection policy violation")
	ErrHostAllowlistRequired = errors.New("catalog: public_url requires a non-empty host allowlist")
	ErrPublicURLRejected     = errors.New("catalog: public_url rejected")
	ErrProhibitedContent     = errors.New("catalog: prohibited public content")
)

Sentinel errors. Callers match these with errors.Is rather than comparing error strings. Every sentinel is prefixed "catalog:" so a wrapped error remains self-identifying when it is logged by a host.

Functions

func Commit

func Commit() string

Commit reports the source revision this binary was built from, or the empty string when it cannot be determined.

func EntityIDPattern added in v0.2.0

func EntityIDPattern() string

EntityIDPattern returns the portable identifier grammar as a string.

Hosts with a stricter grammar of their own should assert that theirs is a subset of this one rather than adopting it; loosening a host grammar to match can admit identifiers the host's own schema rejects.

func ScanPublicText added in v0.2.0

func ScanPublicText(field, value string) error

ScanPublicText reports whether value is safe to publish for the named field. It returns a *PolicyError naming the violated rule, and never reproduces the offending text. Hosts building their own publication gates should call this rather than reimplementing the patterns, so library and host cannot drift.

Example

ScanPublicText lets a host reuse the library's publication rules instead of reimplementing them. The error names the rule and never echoes the value.

package main

import (
	"errors"
	"fmt"
	"strings"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	const rejected = "Built from /Users/someone/private/notes.md"

	err := catalog.ScanPublicText("summary", rejected)
	var policy *catalog.PolicyError
	if errors.As(err, &policy) {
		fmt.Println("rule:", policy.Rule)
		fmt.Println("field:", policy.Field)
		// The rejected text never appears in the rendered error, because that
		// error travels to logs and CI output.
		fmt.Println("error echoes the value:", strings.Contains(policy.Error(), "/Users/someone"))
	}
	fmt.Println("safe text accepted:", catalog.ScanPublicText("summary", "An ordinary summary.") == nil)
}
Output:
rule: path-disclosure
field: summary
error echoes the value: false
safe text accepted: true

func ValidateEntityID added in v0.2.0

func ValidateEntityID(id string) error

ValidateEntityID reports whether id is a well-formed portable entity id.

func Version

func Version() string

Version reports the module version. It is a function rather than a variable so an importing host cannot rewrite the identity the library reports.

Types

type BuildInfo

type BuildInfo struct {
	// Version is the module's released version.
	Version string `json:"version"`
	// Commit is the source revision, when it can be determined.
	Commit string `json:"commit,omitempty"`
	// Modified reports whether the build tree had uncommitted changes.
	Modified bool `json:"modified,omitempty"`
	// SchemaVersion is the on-disk index contract this build reads and writes.
	SchemaVersion int `json:"schema_version"`
	// Capabilities lists the engine features this build advertises.
	Capabilities []Capability `json:"capabilities"`
	// contains filtered or unexported fields
}

BuildInfo identifies the engine and the contract it implements.

func VersionInfo

func VersionInfo() BuildInfo

VersionInfo describes the running engine.

Example
package main

import (
	"fmt"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	info := catalog.VersionInfo()
	fmt.Println("schema:", info.SchemaVersion)
	fmt.Println("bm25:", info.Has(catalog.CapabilityBM25Search))
}
Output:
schema: 2
bm25: true

func (BuildInfo) Has added in v0.2.0

func (b BuildInfo) Has(capability Capability) bool

Has reports whether the build advertises the named capability.

type Capability added in v0.2.0

type Capability string

Capability names an engine feature a host can depend on. It is a closed vocabulary so consumers branch on constants rather than string literals.

const (
	CapabilityProviders        Capability = "providers"
	CapabilityLayout           Capability = "layout"
	CapabilityValidate         Capability = "validate"
	CapabilityReindex          Capability = "reindex"
	CapabilityBM25Search       Capability = "bm25-search"
	CapabilityGraph            Capability = "graph"
	CapabilityDrift            Capability = "drift"
	CapabilityReconcile        Capability = "reconcile"
	CapabilityPublicProjection Capability = "public-projection"
	CapabilitySyntheticDemo    Capability = "synthetic-demo"
	CapabilityExplorer         Capability = "explorer"
	CapabilityExplorerExport   Capability = "explorer-static-export"
	CapabilityReadOnlyMCP      Capability = "read-only-mcp"
)

Capabilities advertised by this build.

func Capabilities added in v0.2.0

func Capabilities() []Capability

Capabilities returns the sorted capability set of this build. The returned slice is a fresh copy; mutating it does not affect the engine.

type CorpusDecision added in v0.2.0

type CorpusDecision struct {
	// Skip reports whether the path is excluded from the corpus.
	Skip bool `json:"skip"`
	// Reason names the rule that excluded it, empty when Skip is false.
	Reason SkipReason `json:"reason,omitempty"`
	// contains filtered or unexported fields
}

CorpusDecision is the outcome of a corpus-membership test.

type CorpusPolicy added in v0.2.0

type CorpusPolicy struct {
	// SkipDotPrefixedDirs excludes directories beginning with ".", which
	// conventionally hold tooling state rather than authored records.
	SkipDotPrefixedDirs bool
	// SkipUnderscorePrefixedDirs excludes directories beginning with "_".
	// This generalizes the older hardcoded _archive rule and is what makes an
	// archive tree structurally invisible rather than invisible by coincidence
	// of naming.
	SkipUnderscorePrefixedDirs bool
	// SkipUnderscorePrefixedFiles excludes files beginning with "_", which
	// hosts commonly use for generated indexes sitting beside authored records.
	SkipUnderscorePrefixedFiles bool
	// SkipDirNames excludes directories by exact name.
	SkipDirNames []string
	// Extensions are the accepted file extensions, lowercase and dot-prefixed.
	// Empty accepts every extension.
	Extensions []string
	// CaseFoldExtensions matches extensions case-insensitively. Hosts whose
	// corpus is compared byte-for-byte against a generated artifact may need
	// this off, so that a newly-added Foo.MD cannot silently join the corpus.
	CaseFoldExtensions bool
	// contains filtered or unexported fields
}

CorpusPolicy decides which directories and files under Layout.CorpusDir are candidate entity records.

It is data rather than behavior, so a host can declare its corpus shape once and have both the engine and the host's own loader consult the same decision. That matters because corpus membership is a privacy and correctness boundary: a tombstoned or generated tree that is skipped by one reader and walked by another silently resurrects entities the host believes are gone.

func DefaultCorpusPolicy added in v0.2.0

func DefaultCorpusPolicy() CorpusPolicy

DefaultCorpusPolicy is the general-purpose policy: skip tooling, dependency, and archive trees, and accept the three authored entity formats.

func StrictMarkdownCorpusPolicy added in v0.2.0

func StrictMarkdownCorpusPolicy() CorpusPolicy

StrictMarkdownCorpusPolicy is for hosts whose corpus is Markdown-only and whose generated indexes live beside the authored records under an underscore prefix. Extension matching is case-sensitive so the accepted set cannot grow by accident.

func (CorpusPolicy) DecideDir added in v0.2.0

func (p CorpusPolicy) DecideDir(name string) CorpusDecision

DecideDir reports whether a directory with the given base name is excluded.

func (CorpusPolicy) DecideFile added in v0.2.0

func (p CorpusPolicy) DecideFile(name string) CorpusDecision

DecideFile reports whether a file with the given base name is excluded.

func (CorpusPolicy) Normalize added in v0.2.0

func (p CorpusPolicy) Normalize() CorpusPolicy

Normalize returns a policy with its name and extension sets trimmed and deduplicated. It is idempotent.

type DecodeError added in v0.2.0

type DecodeError struct {
	// Path is the payload that failed to decode.
	Path string
	// Err is the decoder's error.
	Err error
}

DecodeError reports a corpus payload that could not be decoded.

func (*DecodeError) Error added in v0.2.0

func (e *DecodeError) Error() string

Error names the undecodable path.

func (*DecodeError) Unwrap added in v0.2.0

func (e *DecodeError) Unwrap() error

Unwrap exposes the decoder's error.

type Document

type Document struct {
	EntityID      string         `json:"entity_id"`
	Length        int            `json:"length"`
	TermFrequency map[string]int `json:"term_frequency"`
	// contains filtered or unexported fields
}

Document is the per-entity term-frequency record backing BM25 retrieval.

type DriftReport

type DriftReport struct {
	OK             bool   `json:"ok"`
	Changed        bool   `json:"changed"`
	Reason         string `json:"reason,omitempty"`
	ExpectedDigest string `json:"expected_digest,omitempty"`
	ActualDigest   string `json:"actual_digest,omitempty"`
	// contains filtered or unexported fields
}

DriftReport compares authored source against the derived index.

type DuplicateIDError added in v0.2.0

type DuplicateIDError struct {
	// EntityID is the id claimed twice.
	EntityID string
	// First is the origin encountered first in deterministic order.
	First RecordOrigin
	// Second is the colliding origin.
	Second RecordOrigin
}

DuplicateIDError reports the same entity id arriving from two origins. Both origins are retained so a host can report the collision without re-running discovery.

func (*DuplicateIDError) Error added in v0.2.0

func (e *DuplicateIDError) Error() string

Error names the id and both origins that claimed it.

func (*DuplicateIDError) Unwrap added in v0.2.0

func (e *DuplicateIDError) Unwrap() error

Unwrap reports ErrDuplicateEntityID.

type Engine

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

Engine is a host-bound catalog compiler. It is safe to construct more than one engine with different Layouts in the same process.

func New

func New(layout Layout, opts ...Option) (*Engine, error)

New builds an engine for layout.

With no WithProviders option the engine discovers through a default FilesystemProvider rooted at layout.CorpusDir. Provider names must be unique; duplicates fail closed rather than shadowing one another.

Example
package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

func main() {
	root, err := os.MkdirTemp("", "nicos-catalog-new-")
	if err != nil {
		panic(err)
	}
	defer func() { _ = os.RemoveAll(root) }()

	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout,
		catalog.WithProviders(catalog.StaticProvider{Entities: exampleEntities()}),
		catalog.WithLimits(catalog.Limits{MaxEntities: 100}),
	)
	if err != nil {
		panic(err)
	}
	records, err := engine.Discover(context.Background())
	if err != nil {
		panic(err)
	}
	fmt.Println(len(records), "records")
}
Output:
3 records

func (*Engine) Discover

func (e *Engine) Discover(ctx context.Context) ([]Record, error)

Discover collects, normalizes, validates, and deterministically orders all provider records. Duplicate IDs fail closed even across providers.

func (*Engine) Drift

func (e *Engine) Drift(ctx context.Context) (DriftReport, error)

Drift compares authored source against the derived index. A missing index or a schema advance is reported as drift to reconcile, not as an error.

Example
package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

// exampleEngine builds an engine over a temporary host root.
func exampleEngine() (*catalog.Engine, func()) {
	root, err := os.MkdirTemp("", "nicos-catalog-example-")
	if err != nil {
		panic(err)
	}
	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(
		catalog.StaticProvider{ProviderName: "example", Entities: exampleEntities()},
	))
	if err != nil {
		panic(err)
	}
	return engine, func() { _ = os.RemoveAll(root) }
}

func main() {
	engine, cleanup := exampleEngine()
	defer cleanup()
	ctx := context.Background()

	// Before any reindex there is no derived state to compare against.
	report, err := engine.Drift(ctx)
	if err != nil {
		panic(err)
	}
	fmt.Println("reason:", report.Reason)

	if _, err := engine.Reindex(ctx); err != nil {
		panic(err)
	}
	report, err = engine.Drift(ctx)
	if err != nil {
		panic(err)
	}
	fmt.Println("clean:", report.OK)
}
Output:
reason: index_missing
clean: true

func (*Engine) Layout

func (e *Engine) Layout() Layout

Layout returns the host boundaries this engine was built with.

func (*Engine) LoadIndex

func (e *Engine) LoadIndex(ctx context.Context) (Index, error)

LoadIndex reads the derived index written by Reindex.

func (*Engine) Reconcile

func (e *Engine) Reconcile(ctx context.Context, mode ReconcileMode) (ReconcileReport, error)

Reconcile reports drift and, in ReconcileApply mode, rewrites the index.

Example

Reconcile defaults to a dry run: the zero ReconcileMode never writes.

package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

// exampleEngine builds an engine over a temporary host root.
func exampleEngine() (*catalog.Engine, func()) {
	root, err := os.MkdirTemp("", "nicos-catalog-example-")
	if err != nil {
		panic(err)
	}
	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(
		catalog.StaticProvider{ProviderName: "example", Entities: exampleEntities()},
	))
	if err != nil {
		panic(err)
	}
	return engine, func() { _ = os.RemoveAll(root) }
}

func main() {
	engine, cleanup := exampleEngine()
	defer cleanup()
	ctx := context.Background()

	report, err := engine.Reconcile(ctx, catalog.ReconcileDryRun)
	if err != nil {
		panic(err)
	}
	fmt.Println("drift:", report.Drift.Changed, "applied:", report.Applied)

	report, err = engine.Reconcile(ctx, catalog.ReconcileApply)
	if err != nil {
		panic(err)
	}
	fmt.Println("applied:", report.Applied)
}
Output:
drift: true applied: false
applied: true

func (*Engine) Reindex

func (e *Engine) Reindex(ctx context.Context) (ReindexReport, error)

Reindex discovers, indexes, and atomically installs the derived index. Identical inputs produce byte-identical output.

func (*Engine) Search

func (e *Engine) Search(ctx context.Context, query string, options SearchOptions) ([]SearchResult, error)

Search performs BM25 full-text retrieval over the deterministic local index.

Scores are relative within one result set and are not comparable across queries or across engines: a higher score is a better match here, which is the opposite convention to some host rankers.

func (*Engine) Validate

func (e *Engine) Validate(ctx context.Context, opts ...ValidateOption) (ValidationReport, error)

Validate checks reference integrity across the discovered corpus.

Example
package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

// exampleEngine builds an engine over a temporary host root.
func exampleEngine() (*catalog.Engine, func()) {
	root, err := os.MkdirTemp("", "nicos-catalog-example-")
	if err != nil {
		panic(err)
	}
	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(
		catalog.StaticProvider{ProviderName: "example", Entities: exampleEntities()},
	))
	if err != nil {
		panic(err)
	}
	return engine, func() { _ = os.RemoveAll(root) }
}

func main() {
	engine, cleanup := exampleEngine()
	defer cleanup()

	report, err := engine.Validate(context.Background())
	if err != nil {
		panic(err)
	}
	fmt.Println("ok:", report.OK, "entities:", report.EntityCount)
}
Output:
ok: true entities: 3

type Entity

type Entity struct {
	// ID is the stable identifier, unique across every provider.
	ID string `json:"id" yaml:"id"`
	// Name is the human-facing label.
	Name string `json:"name" yaml:"name"`
	// Kind is the host's classification, such as service or system. The engine
	// does not constrain the vocabulary.
	Kind string `json:"kind" yaml:"kind"`
	// Surface is an optional host-internal grouping. It is never published.
	Surface string `json:"surface,omitempty" yaml:"surface,omitempty"`
	// Status is an optional lifecycle label such as shipped or experimental.
	Status string `json:"status,omitempty" yaml:"status,omitempty"`
	// Description is prose. A markdown provider fills it from the body when the
	// frontmatter omits it. Publication truncates and scans it into Summary.
	Description string `json:"description,omitempty" yaml:"description,omitempty"`
	// Entrypoint locates the entity in the host's own tree. It is never
	// published, because it discloses a filesystem or command layout.
	Entrypoint string `json:"entrypoint,omitempty" yaml:"entrypoint,omitempty"`
	// Owner attributes the entity inside the host. It is never published.
	Owner string `json:"owner,omitempty" yaml:"owner,omitempty"`
	// Tags are free-form labels. They are normalized, deduped, and sorted, and
	// they drive the projection include and exclude filters.
	Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
	// Refs are typed relationships to other entities. Targets need not exist;
	// Validate reports the dangling ones.
	Refs []Ref `json:"refs,omitempty" yaml:"refs,omitempty"`
	// PublicURL is the entity's canonical public link. Projecting one requires a
	// non-empty ProjectionPolicy.AllowHosts.
	PublicURL string `json:"public_url,omitempty" yaml:"public_url,omitempty"`
	// Visibility governs publication. Only VisibilityPublic is projectable.
	Visibility Visibility `json:"visibility,omitempty" yaml:"visibility,omitempty"`
	// Annotations carry arbitrary host data. They are never published, and the
	// closed public DTO is structurally unable to represent them.
	Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
	// contains filtered or unexported fields
}

Entity is the portable catalog record. Host-only business, telemetry, and operator fields belong in host adapters rather than this public contract.

type EntityError added in v0.2.0

type EntityError struct {
	// EntityID is the offending entity, when it could be read.
	EntityID string
	// Provider supplied the entity.
	Provider string
	// Source located it within that provider.
	Source string
	// Field names the offending field, when the failure is field-scoped.
	Field string
	// Err is the underlying cause.
	Err error
}

EntityError wraps a failure attributed to a single entity.

func (*EntityError) Error added in v0.2.0

func (e *EntityError) Error() string

Error names the entity and, when known, the offending field.

func (*EntityError) Unwrap added in v0.2.0

func (e *EntityError) Unwrap() error

Unwrap exposes the underlying cause.

type FilesystemProvider

type FilesystemProvider struct {
	// ProviderName identifies the provider. Empty selects "filesystem".
	ProviderName string
	// ExcludeDirs names additional directories to skip, on top of the policy's
	// own rules. It is folded into Policy.SkipDirNames during normalization.
	ExcludeDirs []string
	// Policy decides corpus membership. The zero value selects
	// DefaultCorpusPolicy.
	Policy CorpusPolicy
	// Strict rejects unknown fields, malformed frontmatter, trailing documents,
	// and entity files without IDs. Public and release paths should enable it.
	Strict bool
}

FilesystemProvider reads .md YAML frontmatter, .yaml/.yml, and .json entity records recursively from Layout.CorpusDir.

Example
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	root, err := os.MkdirTemp("", "nicos-catalog-fs-")
	if err != nil {
		panic(err)
	}
	defer func() { _ = os.RemoveAll(root) }()

	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	if err := os.MkdirAll(layout.CorpusDir, 0o755); err != nil {
		panic(err)
	}
	if err := os.WriteFile(filepath.Join(layout.CorpusDir, "service.press.yaml"),
		[]byte("id: service.press\nname: Press API\nkind: service\n"), 0o644); err != nil {
		panic(err)
	}
	records, err := catalog.FilesystemProvider{Strict: true}.
		Provide(context.Background(), layout)
	if err != nil {
		panic(err)
	}
	fmt.Println(records[0].Entity.ID, "from", records[0].Source)
}
Output:
service.press from service.press.yaml

func (FilesystemProvider) Name

func (p FilesystemProvider) Name() string

Name reports the provider's identity.

func (FilesystemProvider) Provide

func (p FilesystemProvider) Provide(ctx context.Context, layout Layout) ([]Record, error)

Provide walks Layout.CorpusDir and decodes every entity file it accepts. Symlinked files are refused rather than followed.

type Graph

type Graph struct {
	Nodes []GraphNode `json:"nodes"`
	Edges []GraphEdge `json:"edges"`
}

Graph is the compiled entity relationship graph.

func BuildGraph

func BuildGraph(index Index) Graph

BuildGraph compiles the typed relationship graph from an index. Nodes follow entity order; edges are sorted by source, kind, then target so the result is byte-stable.

Example
package main

import (
	"fmt"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	index := catalog.Index{Entities: []catalog.Entity{
		{ID: "system.orchard", Name: "Orchard", Kind: "system",
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}}},
		{ID: "service.press", Name: "Press API", Kind: "service"},
	}}
	graph := catalog.BuildGraph(index)
	fmt.Println(len(graph.Nodes), "nodes,", len(graph.Edges), "edges")
}
Output:
2 nodes, 1 edges

func (Graph) Mermaid

func (g Graph) Mermaid() string

Mermaid renders the graph as a Mermaid flowchart. Labels are escaped so that no name or kind can break the diagram's line structure.

type GraphEdge

type GraphEdge struct {
	Source string `json:"source"`
	Kind   string `json:"kind"`
	Target string `json:"target"`
}

GraphEdge is one typed relationship between two entities. A Target need not correspond to a Node; the host decides how to render a dangling edge.

type GraphNode

type GraphNode struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Kind   string `json:"kind"`
	Status string `json:"status,omitempty"`
}

GraphNode is one entity in the graph.

type Index

type Index struct {
	SchemaVersion         int        `json:"schema_version"`
	SourceDigest          string     `json:"source_digest"`
	Entities              []Entity   `json:"entities"`
	Documents             []Document `json:"documents"`
	AverageDocumentLength float64    `json:"average_document_length"`
	// contains filtered or unexported fields
}

Index is the deterministic, portable catalog cache. It intentionally omits wall-clock timestamps so identical inputs produce byte-identical output.

type IndexError added in v0.2.0

type IndexError struct {
	// Path is the index file involved.
	Path string
	// Err is the underlying cause, wrapping an Err* sentinel.
	Err error
}

IndexError reports a failure reading or writing the derived index.

func (*IndexError) Error added in v0.2.0

func (e *IndexError) Error() string

Error names the index path.

func (*IndexError) Unwrap added in v0.2.0

func (e *IndexError) Unwrap() error

Unwrap exposes the underlying cause.

type Layout

type Layout struct {
	// CorpusDir holds authored entity files. It is the only directory the
	// engine reads as input.
	CorpusDir string `json:"corpus_dir" yaml:"corpus_dir"`
	// ConfigDir holds host configuration. The engine does not read it; it is
	// carried so a host has one place to describe all four boundaries.
	ConfigDir string `json:"config_dir" yaml:"config_dir"`
	// CacheDir holds derived state, including the index. It must not be nested
	// beneath CorpusDir, or generated output would become authored input on the
	// next run.
	CacheDir string `json:"cache_dir" yaml:"cache_dir"`
	// SidecarDataDir holds host-owned data adjacent to the catalog. The engine
	// does not read or write it.
	SidecarDataDir string `json:"sidecar_data_dir" yaml:"sidecar_data_dir"`
}

Layout injects every host-owned filesystem boundary used by the engine. The engine never assumes a repository name, home directory, or corpus shape.

func DefaultLayout

func DefaultLayout(root string) Layout

DefaultLayout returns a portable layout rooted at root.

Example
package main

import (
	"fmt"
	"path/filepath"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	layout := catalog.DefaultLayout("/srv/host")
	fmt.Println(filepath.ToSlash(layout.CorpusDir))
	fmt.Println(filepath.ToSlash(layout.CacheDir))
}
Output:
/srv/host/catalog
/srv/host/.nicos-catalog/cache

func (Layout) Resolve

func (l Layout) Resolve(root string) (Layout, error)

Resolve converts relative paths to absolute paths under root and validates that all four host boundaries are explicit and distinct where required.

func (Layout) Validate

func (l Layout) Validate() error

Validate rejects ambiguous layouts and unsafe cache placement.

type Limits added in v0.2.0

type Limits struct {
	// MaxEntities caps the total number of records Discover will return.
	MaxEntities int
	// MaxRecordsPerProvider caps the records a single provider may contribute.
	MaxRecordsPerProvider int
	// MaxSourceBytes caps the size of an individual corpus file.
	MaxSourceBytes int64
	// MaxSummaryBytes is the default projection summary bound when a
	// ProjectionPolicy does not set its own.
	MaxSummaryBytes int
	// MaxSearchResults caps the result count a single Search may return.
	MaxSearchResults int
	// contains filtered or unexported fields
}

Limits bounds the work an engine will accept. Every zero value means "unlimited" for that dimension, except MaxSummaryBytes, which falls back to the projection default.

func DefaultLimits added in v0.2.0

func DefaultLimits() Limits

DefaultLimits returns the unbounded configuration the engine uses when a host declares none.

func (Limits) Validate added in v0.2.0

func (l Limits) Validate() error

Validate rejects negative bounds.

type Option added in v0.2.0

type Option func(*engineConfig) error

Option configures an Engine at construction time. Options exist so the constructor can grow without another breaking signature change.

func WithLimits added in v0.2.0

func WithLimits(limits Limits) Option

WithLimits bounds engine work.

func WithLogger added in v0.2.0

func WithLogger(logger *slog.Logger) Option

WithLogger attaches a structured logger.

The engine logs only counts, durations, provider names, entity ids, and corpus-relative paths. It never logs entity descriptions, annotations, public URLs, owners, or entrypoints, so a host can route this logger anywhere its operational logs already go.

func WithProviders added in v0.2.0

func WithProviders(providers ...Provider) Option

WithProviders registers the providers the engine discovers from. Passing no providers leaves the engine with its default FilesystemProvider.

type PolicyError added in v0.2.0

type PolicyError struct {
	// EntityID is the entity that failed, when the check was entity-scoped.
	EntityID string
	// Field names the offending field, such as summary or tags[2].
	Field string
	// Rule is the publication rule that was violated.
	Rule PolicyRule
	// Err is the underlying sentinel.
	Err error
}

PolicyError reports that a value was refused publication.

It deliberately carries no copy of the offending text. The rejected value is the exact thing publication is meant to contain, and this error travels to stderr, CI logs, and host error paths; reproducing the match there would defeat the boundary the projection exists to enforce. Callers get the entity, the field, and the rule, which is enough to locate the value in the source they already control.

func (*PolicyError) Error added in v0.2.0

func (e *PolicyError) Error() string

Error renders the entity, field, and rule. It never includes the rejected value.

func (*PolicyError) Unwrap added in v0.2.0

func (e *PolicyError) Unwrap() error

Unwrap exposes the underlying sentinel.

type PolicyRule added in v0.2.0

type PolicyRule string

PolicyRule names the publication rule a value violated. It is a closed vocabulary so hosts can branch on the cause without parsing error text.

const (
	RulePathDisclosure PolicyRule = "path-disclosure"
	RuleInternalPath   PolicyRule = "internal-path"
	RuleCredentialPair PolicyRule = "credential-field-shape" //nolint:gosec // G101: rule id in closed vocabulary, not a secret value
	RuleTokenShape     PolicyRule = "token-shape"
	RuleInvalidUTF8    PolicyRule = "invalid-utf8"
	RuleURLScheme      PolicyRule = "url-scheme"
	RuleURLCredentials PolicyRule = "url-credentials"
	RuleURLQuery       PolicyRule = "url-query-or-fragment"
	RuleURLPort        PolicyRule = "url-port"
	RuleURLHost        PolicyRule = "url-host-not-allowed"
	RuleVisibility     PolicyRule = "visibility"
)

Publication rules reported by PolicyError.

type ProjectionPolicy

type ProjectionPolicy struct {
	// RequireVisibility is the visibility an entity must declare to be
	// projected. The empty value means VisibilityPublic; no other value is
	// accepted, because only public entities are publishable.
	RequireVisibility Visibility
	// IncludeKinds limits the projection to these kinds. Matching is
	// case-insensitive, consistent with Search. Empty means every kind.
	IncludeKinds []string
	// IncludeTags limits the projection to entities carrying at least one of
	// these tags. Empty means no tag filtering.
	IncludeTags []string
	// ExcludeTags removes entities carrying any of these tags. A denylist match
	// beats an IncludeTags match, so a tag can be used to withhold an entity
	// that would otherwise qualify.
	ExcludeTags []string
	// AllowHosts is the exact-match hostname allowlist for PublicURL. It must
	// be non-empty whenever any projected entity declares a PublicURL;
	// otherwise projection fails closed. Subdomains are not implied.
	AllowHosts []string
	// URLMode selects allowlist enforcement. The zero value rejects.
	URLMode URLMode
	// MaxSummaryBytes bounds the emitted summary, including the truncation
	// marker. Zero or negative selects the 320-byte default.
	MaxSummaryBytes int
	// TruncationSuffix marks a shortened summary. Empty selects "…". Its bytes
	// are charged against MaxSummaryBytes rather than added after it.
	TruncationSuffix string
	// contains filtered or unexported fields
}

ProjectionPolicy constrains what ProjectPublic will emit.

func (ProjectionPolicy) Validate added in v0.2.0

func (p ProjectionPolicy) Validate() error

Validate rejects policies that cannot be satisfied, so a host can fail at config load rather than at publication time.

type Provider

type Provider interface {
	Name() string
	Provide(context.Context, Layout) ([]Record, error)
}

Provider is the host extension boundary. Providers discover authored facts; the engine owns normalization, validation, indexing, graph, and drift.

type ProviderError added in v0.2.0

type ProviderError struct {
	// Provider is the failing provider's name.
	Provider string
	// Source is the record location, when the failure is attributable to one.
	Source string
	// Err is the provider's own error.
	Err error
}

ProviderError wraps a failure attributed to a named provider.

func (*ProviderError) Error added in v0.2.0

func (e *ProviderError) Error() string

Error names the provider and, when known, the source.

func (*ProviderError) Unwrap added in v0.2.0

func (e *ProviderError) Unwrap() error

Unwrap exposes the provider's own error.

type PublicConnection

type PublicConnection struct {
	Kind   string `json:"kind"`
	Target string `json:"target"`
	// contains filtered or unexported fields
}

PublicConnection is a reference between two entities that both survived the projection filter. References to excluded entities are dropped entirely.

type PublicEntity

type PublicEntity struct {
	ID          string             `json:"id"`
	Name        string             `json:"name"`
	Kind        string             `json:"kind"`
	Status      string             `json:"status,omitempty"`
	Summary     string             `json:"summary,omitempty"`
	Tags        []string           `json:"tags,omitempty"`
	URL         string             `json:"url,omitempty"`
	Connections []PublicConnection `json:"connections,omitempty"`
	// contains filtered or unexported fields
}

PublicEntity is a closed publication DTO. It cannot represent source paths, host annotations, owner telemetry, sidecars, valuation, or query text.

The field set is frozen by TestPublicEntityShapeIsFrozen. Adding a field is a deliberate privacy decision, not a routine change.

type PublicProjection

type PublicProjection struct {
	SchemaVersion int            `json:"schema_version"`
	Items         []PublicEntity `json:"items"`
	// contains filtered or unexported fields
}

PublicProjection is the closed publication artifact. Publication should consume this DTO rather than filtering a private index after serialization.

func ProjectPublic

func ProjectPublic(ctx context.Context, index Index, policy ProjectionPolicy) (PublicProjection, error)

ProjectPublic compiles the closed public projection for index under policy. It fails closed: any entity that violates the policy aborts the projection rather than being silently dropped.

Example

ProjectPublic emits only entities that declare public visibility, and only the fields the closed DTO can represent.

package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

// exampleEntities is a small synthetic corpus shared by the examples.
func exampleEntities() []catalog.Entity {
	return []catalog.Entity{
		{
			ID: "system.orchard", Name: "Orchard", Kind: "system",
			Description: "Ownership graph for the platform.",
			Tags:        []string{"platform"}, Visibility: catalog.VisibilityPublic,
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.press"}},
		},
		{
			ID: "service.press", Name: "Press API", Kind: "service",
			Description: "Inventory and dependency search.",
			Tags:        []string{"go"}, Visibility: catalog.VisibilityPublic,
		},
		{
			ID: "telemetry.sample", Name: "Query Sample", Kind: "telemetry",
			Description: "Host-only.", Visibility: catalog.VisibilityPrivate,
			Owner: "platform-team", Entrypoint: "cmd/sample/main.go",
		},
	}
}

// exampleEngine builds an engine over a temporary host root.
func exampleEngine() (*catalog.Engine, func()) {
	root, err := os.MkdirTemp("", "nicos-catalog-example-")
	if err != nil {
		panic(err)
	}
	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(
		catalog.StaticProvider{ProviderName: "example", Entities: exampleEntities()},
	))
	if err != nil {
		panic(err)
	}
	return engine, func() { _ = os.RemoveAll(root) }
}

func main() {
	engine, cleanup := exampleEngine()
	defer cleanup()
	ctx := context.Background()
	if _, err := engine.Reindex(ctx); err != nil {
		panic(err)
	}
	index, err := engine.LoadIndex(ctx)
	if err != nil {
		panic(err)
	}
	projection, err := catalog.ProjectPublic(ctx, index, catalog.ProjectionPolicy{})
	if err != nil {
		panic(err)
	}
	for _, item := range projection.Items {
		fmt.Println(item.ID, "|", item.Kind, "|", len(item.Connections), "connections")
	}
}
Output:
service.press | service | 0 connections
system.orchard | system | 1 connections
Example (HostAllowlist)

AllowHosts must be non-empty whenever any projected entity declares a PublicURL. An empty allowlist is a hard error rather than an implicit permit, so forgetting to configure one cannot publish an unreviewed link.

package main

import (
	"context"
	"errors"
	"fmt"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	index := catalog.Index{Entities: []catalog.Entity{{
		ID: "service.press", Name: "Press API", Kind: "service",
		Visibility: catalog.VisibilityPublic,
		PublicURL:  "https://example.com/press",
	}}}
	ctx := context.Background()

	_, err := catalog.ProjectPublic(ctx, index, catalog.ProjectionPolicy{})
	fmt.Println("without an allowlist:", errors.Is(err, catalog.ErrHostAllowlistRequired))

	projection, err := catalog.ProjectPublic(ctx, index, catalog.ProjectionPolicy{
		AllowHosts: []string{"example.com"},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("with an allowlist:", projection.Items[0].URL)
}
Output:
without an allowlist: true
with an allowlist: https://example.com/press

type ReconcileMode added in v0.2.0

type ReconcileMode int

ReconcileMode selects whether Reconcile may write. The zero value is ReconcileDryRun, so a caller that forgets to choose cannot mutate anything.

const (
	// ReconcileDryRun reports drift without writing. It is the zero value.
	ReconcileDryRun ReconcileMode = iota
	// ReconcileApply rebuilds the index when drift exists.
	ReconcileApply
)

Reconcile modes.

func (ReconcileMode) MarshalJSON added in v0.2.0

func (m ReconcileMode) MarshalJSON() ([]byte, error)

MarshalJSON renders the mode as a string and rejects unknown values.

func (ReconcileMode) String added in v0.2.0

func (m ReconcileMode) String() string

String renders the mode as its wire value.

func (*ReconcileMode) UnmarshalJSON added in v0.2.0

func (m *ReconcileMode) UnmarshalJSON(payload []byte) error

UnmarshalJSON accepts only the known mode names.

func (ReconcileMode) Valid added in v0.2.0

func (m ReconcileMode) Valid() bool

Valid reports whether m is a recognized mode.

type ReconcileReport

type ReconcileReport struct {
	Drift   DriftReport    `json:"drift"`
	Mode    ReconcileMode  `json:"mode"`
	Applied bool           `json:"applied"`
	Reindex *ReindexReport `json:"reindex,omitempty"`
	// contains filtered or unexported fields
}

ReconcileReport records what a reconcile observed and whether it wrote.

type Record

type Record struct {
	// Entity is the normalized portable record.
	Entity Entity `json:"entity"`
	// Provider names the provider that supplied it.
	Provider string `json:"provider"`
	// Source locates the record within that provider, such as a corpus-relative
	// path. It is provenance for the host and is never published.
	Source string `json:"source"`
	// Digest is the canonical digest of the normalized entity, assigned by the
	// engine during Discover. It changes only when this entity changes.
	Digest string `json:"digest"`
	// SourceDigest is the digest of the whole payload the entity was read from.
	// Entities sharing one multi-entity file share a SourceDigest.
	SourceDigest string `json:"source_digest,omitempty"`
	// contains filtered or unexported fields
}

Record binds a portable entity to source provenance. Source paths never appear in public projections.

type RecordOrigin added in v0.2.0

type RecordOrigin struct {
	// Provider named the record's source provider.
	Provider string
	// Source located the record within that provider.
	Source string
	// contains filtered or unexported fields
}

RecordOrigin identifies where a record entered the engine.

type Ref

type Ref struct {
	// Kind names the relationship, such as contains or depends_on.
	Kind string `json:"kind" yaml:"kind"`
	// Target is the id of the referenced entity.
	Target string `json:"target" yaml:"target"`
	// contains filtered or unexported fields
}

Ref is a typed relationship from one entity to another.

type ReindexReport

type ReindexReport struct {
	OK            bool   `json:"ok"`
	EntityCount   int    `json:"entity_count"`
	DocumentCount int    `json:"document_count"`
	SourceDigest  string `json:"source_digest"`
	IndexPath     string `json:"index_path"`
	// contains filtered or unexported fields
}

ReindexReport summarizes a completed reindex.

type SearchOptions

type SearchOptions struct {
	// Limit bounds the returned results. Zero selects the default of ten.
	Limit int
	// Kinds restricts results to these kinds, matched case-insensitively.
	Kinds []string
	// contains filtered or unexported fields
}

SearchOptions tunes a single query.

type SearchResult

type SearchResult struct {
	Entity       Entity   `json:"entity"`
	Score        float64  `json:"score"`
	MatchedTerms []string `json:"matched_terms"`
	// contains filtered or unexported fields
}

SearchResult is one scored match.

type Severity added in v0.2.0

type Severity string

Severity classifies a validation issue.

const (
	// SeverityError fails a ValidationReport.
	SeverityError Severity = "error"
	// SeverityWarning is advisory and leaves a report OK.
	SeverityWarning Severity = "warning"
)

Issue severities.

type SkipReason added in v0.2.0

type SkipReason string

SkipReason names why a corpus path was excluded. It is a closed vocabulary so a host can report and assert on the decision rather than re-deriving it.

const (
	// SkipNone means the path is a candidate entity record.
	SkipNone SkipReason = ""
	// SkipDotDir excludes a dot-prefixed directory.
	SkipDotDir SkipReason = "dot-prefixed-dir"
	// SkipUnderscoreDir excludes an underscore-prefixed directory.
	SkipUnderscoreDir SkipReason = "underscore-prefixed-dir"
	// SkipDeniedDir excludes a directory named in SkipDirNames.
	SkipDeniedDir SkipReason = "denied-dir-name"
	// SkipUnderscoreFile excludes an underscore-prefixed file.
	SkipUnderscoreFile SkipReason = "underscore-prefixed-file"
	// SkipUnknownExtension excludes a file whose extension is not accepted.
	SkipUnknownExtension SkipReason = "unaccepted-extension"
)

Corpus skip reasons.

type StaticProvider

type StaticProvider struct {
	// ProviderName identifies the provider. Empty selects "static".
	ProviderName string
	// Entities are served as-is. The engine copies before normalizing, so this
	// slice is never rewritten.
	Entities []Entity
}

StaticProvider is useful for tests, embedded demos, and API-backed hosts.

func (StaticProvider) Name

func (p StaticProvider) Name() string

Name reports the provider's identity.

func (StaticProvider) Provide

func (p StaticProvider) Provide(_ context.Context, _ Layout) ([]Record, error)

Provide returns one record per configured entity.

type URLMode added in v0.2.0

type URLMode int

URLMode selects what happens to an entity URL that fails the allowlist. The zero value rejects, so a caller that forgets to choose cannot publish one.

const (
	// URLModeAllowlist fails the projection when a URL is not allowlisted.
	URLModeAllowlist URLMode = iota
	// URLModeDrop omits the URL and keeps the entity.
	URLModeDrop
)

URL handling modes.

type ValidateOption added in v0.2.0

type ValidateOption func(*validateConfig) error

ValidateOption tunes a Validate run.

func WithStrictReferences added in v0.2.0

func WithStrictReferences() ValidateOption

WithStrictReferences promotes dangling references from warnings to errors, so a corpus that points at entities it does not contain fails validation.

Example

WithStrictReferences promotes a dangling reference from a warning to an error, which is what a publication gate usually wants.

package main

import (
	"context"
	"fmt"
	"os"

	catalog "github.com/nstranquist/nicos-catalog"
)

func main() {
	root, err := os.MkdirTemp("", "nicos-catalog-strict-")
	if err != nil {
		panic(err)
	}
	defer func() { _ = os.RemoveAll(root) }()

	layout, err := catalog.DefaultLayout(root).Resolve(root)
	if err != nil {
		panic(err)
	}
	engine, err := catalog.New(layout, catalog.WithProviders(catalog.StaticProvider{
		Entities: []catalog.Entity{{
			ID: "system.alpha", Name: "Alpha", Kind: "system",
			Refs: []catalog.Ref{{Kind: "contains", Target: "service.absent"}},
		}},
	}))
	if err != nil {
		panic(err)
	}
	lenient, _ := engine.Validate(context.Background())
	strict, _ := engine.Validate(context.Background(), catalog.WithStrictReferences())
	fmt.Println("lenient ok:", lenient.OK, "strict ok:", strict.OK)
}
Output:
lenient ok: true strict ok: false

type ValidationIssue added in v0.2.0

type ValidationIssue struct {
	EntityID string              `json:"entity_id"`
	Kind     ValidationIssueKind `json:"kind"`
	Severity Severity            `json:"severity"`
	Detail   string              `json:"detail"`
	// contains filtered or unexported fields
}

ValidationIssue is a single typed finding against the discovered corpus.

type ValidationIssueKind added in v0.2.0

type ValidationIssueKind string

ValidationIssueKind is the closed vocabulary of validation findings.

const (
	// IssueDanglingReference is a reference whose target is not in the corpus.
	IssueDanglingReference ValidationIssueKind = "dangling_reference"
	// IssueSelfReference is an entity referencing itself.
	IssueSelfReference ValidationIssueKind = "self_reference"
	// IssueDuplicateReference is the same kind and target declared twice.
	IssueDuplicateReference ValidationIssueKind = "duplicate_reference"
)

Validation issue kinds.

type ValidationReport

type ValidationReport struct {
	OK            bool              `json:"ok"`
	EntityCount   int               `json:"entity_count"`
	ProviderCount int               `json:"provider_count"`
	Warnings      []ValidationIssue `json:"warnings,omitempty"`
	Errors        []ValidationIssue `json:"errors,omitempty"`
	// contains filtered or unexported fields
}

ValidationReport summarizes a Validate run. OK is false whenever Errors is non-empty; warnings alone do not fail a report.

type Visibility added in v0.2.0

type Visibility string

Visibility is the closed vocabulary controlling whether an entity may be published. Only VisibilityPublic is projectable.

const (
	// VisibilityPublic marks an entity as publishable.
	VisibilityPublic Visibility = "public"
	// VisibilityInternal marks an entity as host-wide but unpublishable.
	VisibilityInternal Visibility = "internal"
	// VisibilityPrivate marks an entity as restricted to its owner.
	VisibilityPrivate Visibility = "private"
)

The visibility vocabulary.

func (Visibility) String added in v0.2.0

func (v Visibility) String() string

String renders the visibility as its wire value.

func (Visibility) Valid added in v0.2.0

func (v Visibility) Valid() bool

Valid reports whether v is a recognized visibility. The empty value is valid and means "unset", which is never projectable.

Directories

Path Synopsis
cmd
nicos-catalog command
Package explorerassets exposes the committed production Explorer bundle.
Package explorerassets exposes the committed production Explorer bundle.
internal
explorerapi
Package explorerapi compiles closed Explorer projections and serves the bounded read-only query contract shared by HTTP, static export, and MCP.
Package explorerapi compiles closed Explorer projections and serves the bounded read-only query contract shared by HTTP, static export, and MCP.
explorerbundle
Package explorerbundle compiles deterministic public static Explorer output.
Package explorerbundle compiles deterministic public static Explorer output.
explorercontract
Package explorercontract owns the versioned data contract shared by the Explorer HTTP API, static bundle, CLI receipts, and MCP transport.
Package explorercontract owns the versioned data contract shared by the Explorer HTTP API, static bundle, CLI receipts, and MCP transport.
explorerinit
Package explorerinit creates a safe starter corpus without overwriting files.
Package explorerinit creates a safe starter corpus without overwriting files.
explorermcp
Package explorermcp exposes bounded read-only Explorer tools over MCP stdio.
Package explorermcp exposes bounded read-only Explorer tools over MCP stdio.
exploreropen
Package exploreropen opens a verified loopback Explorer URL with the host's standard browser launcher.
Package exploreropen opens a verified loopback Explorer URL with the host's standard browser launcher.
explorerweb
Package explorerweb serves the embedded Explorer application with a safe SPA fallback for known client routes.
Package explorerweb serves the embedded Explorer application with a safe SPA fallback for known client routes.

Jump to

Keyboard shortcuts

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