graphene

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 11 Imported by: 0

README

GrapheneDB

GrapheneDB Logo

GrapheneDB is an experimental embeddable Go graph engine for teams that need to ingest big connected datasets fast, keep them durable on disk, and run graph queries without external infrastructure.

Project Status

GrapheneDB is currently in an experimental, pre-production stage.

  • The core architecture and APIs are implemented.
  • Benchmarks and stress tests are available, but coverage is still growing.
  • It is not startup-ready or production-ready yet.
  • The on-disk backend is still maturing toward a fuller property-graph feature set.
  • Treat current performance numbers as early signals, not final guarantees.

Why It Exists

  • Build once, query many times: optimized for heavy ingest followed by read-heavy graph analysis.
  • Zero external runtime: no server to manage, no JVM, no sidecar.
  • Typed graph model: predictable APIs with domain-friendly node and edge labels.
  • Durable by design: WAL-backed persistence with replay and explicit compaction.
  • Full mutation lifecycle: create, read, update, and delete nodes and edges — deletes cascade to incident edges and persist across restart.

Benchmarked Snapshot (Early Signal)

The project includes repeatable benchmark and stress suites. Latest benchmark results:

Benchmarking Conditions:

  • Date: 2026-07-21
  • OS: Windows 11 (amd64)
  • Go Version: go1.26.2
  • Hardware: AMD Ryzen 9 5980HS with Radeon Graphics (16 cores)
  • Architecture: amd64
  • Command: ./test.ps1 -Bench -BenchTime 1s

Core operations:

Benchmark Result Memory
Add node 814.0 ns/op 306 B/op, 3 allocs/op
Get node 5.815 ns/op 0 B/op, 0 allocs/op
BFS traversal 353200 ns/op 234240 B/op, 77 allocs/op
Shortest path 188300 ns/op 100472 B/op, 576 allocs/op
Property index lookup 42.78 ns/op 8 B/op, 1 alloc/op

The suite now runs 68 benchmarks covering reads, writes, concurrency, a 10k→100k scale sweep, and resident memory footprint — up from 5. See benchmarks.md for methodology and the full table.

Query operations — measured on a 100,000-node / 201,000-edge graph with 300,000 indexed property entries, disk backend compacted to CSR:

Benchmark In-memory On-disk
Point lookup by ID 26.46 ns/op 50.09 ns/op
Node in-degree (1000-edge hub) 28.79 ns/op 15.46 ns/op
Equality property query 567.8 ns/op 539.9 ns/op
NodesByType, selective label 227.1 ns/op 4.995 µs/op
Typed query with Limit: 10 12.40 µs/op 20.47 µs/op
Anchored relation query 181.2 µs/op 206.6 µs/op
1-hop neighbours 179.2 ns/op 410.1 ns/op
3-hop BFS 2.799 µs/op 4.376 µs/op

Traversal — allocations per walk, the metric that governs GC pressure here:

Benchmark Time Allocations
BFS, 10,000-node chain 6.935 ms/op 216
BFSIDs, same walk (IDs only) 3.819 ms/op 97
BFS, 100×100 fan-out 5.326 ms/op 232
BFSIDs, same walk 2.612 ms/op 104
BFS on disk, 12 hops 113.9 µs/op 394
BFSIDs on disk, same walk 36.34 µs/op 20

Durability — 50,000-node store with 100,000 indexed property entries:

Benchmark Result
Reopen a compacted store 62.26 ms/op
Compaction, steady state 39.22 ms/op
VerifyIndexes (100k nodes) 205.8 ms/op
Indexing & durability upgrade (2026-07-21)

The read path was reworked so queries start from an index rather than a full enumeration of the graph, and the property index is now stored in the CSR file instead of being rebuilt from the WAL on every restart.

Measured as an interleaved A/B against 036aac0, 6 samples per side. The interleaving is not incidental: measuring the two sides back to back produced a ~25% shift on benchmarks the changes never touched. See benchmarks.md.

Operation Before After Change
Equality property query (disk) 53.55 ms 277.0 ns ~193 000× faster
Equality property query (memory) 44.00 ms 320.1 ns ~137 000× faster
Type + property query (memory) 58.71 ms 7.171 µs ~8 200× faster
Node in-degree on a hub (disk) 74.33 µs 15.18 ns ~4 900× faster
Typed query with Limit: 10 (disk) 17.72 ms 22.29 µs ~795× faster
DeleteNode with a populated index 1.423 ms 2.075 µs ~686× faster
Edge query by type (memory) 56.35 ms 155.3 µs ~363× faster
Anchored relation query (disk) 43.70 ms 228.7 µs ~191× faster
NodesByType on a selective label 445.7 µs 5.059 µs ~88× faster
Reopen a compacted store 1 284 ms 73.60 ms ~17× faster
Compaction, steady state 648.8 ms 64.01 ms ~10× faster
UpdateNode in a 50k-member label 49.16 µs 5.162 µs 9.5× faster
DeleteNode from a 50k-member label 31.31 µs 4.014 µs 7.8× faster

Cost no longer tracks the graph. A 10× larger graph used to make an equality query 14× slower (2.91 ms → 41.38 ms); it is now flat (704 ns → 590 ns).

Traversal was reworked separately, where the metric is allocations per walk rather than latency — allocation is what the GC turns into tail latency:

Operation Before After Change
BFS over a 10,000-node chain 30,190 allocs 216 allocs ~140× fewer
BFS over a 1,000-node chain 3,058 allocs 77 allocs ~40× fewer
BFS over a 100×100 fan-out 1,323 allocs 232 allocs ~5.7× fewer
Shortest path 1,563 allocs 576 allocs ~2.7× fewer
BFS on the disk backend 913 allocs 394 allocs ~2.3× fewer

Wall-clock improved alongside it (disk BFS −21%, shortest path −10%), and the new BFSIDs walks the graph without building a single record: 20 allocations where the record-returning walk needs 394.

Allocation dropped alongside latency: a filtered query that used to allocate 93 MB now allocates 576 bytes, and hub degree counting allocates nothing at all. Geomean across the shared benchmark set at the time of that comparison: −88.7% — and several of the worst paths have improved substantially since, so treat it as a floor rather than a current figure. Full numbers, and the methodology that makes them quotable, are in benchmarks.md.

Since then: pattern matching 24.6 ms → 4.75 ms (399 950 → 150 allocations), cold open 1 263.9 ms → 42.7 ms, and unindexed range scans ~32 ms → ~9.4 ms.

Restart and compaction no longer scale with index size. Before, every Compact() re-emitted the entire property index into the fresh WAL and every restart replayed it; now the index lives in the CSR file and the WAL is left empty after a compaction.

What it cost

The property index now uses 30–65% more memory per node. This is the largest regression in the project and it is the direct price of the speed above:

Footprint (B/node) Before After
Topology only 446.1 446.2 (+0.02%)
No property index 170.1 170.2 (+0.06%)
With property index (memory) 563.8 744.6 (+32.1%)
With property index (disk) 388.4 597.0 (+53.7%)
Index at cardinality 1 179.0 263.6 (+47.3%)
Index, all values distinct 281.1 333.7 (+18.7%)

The first two rows are controls at ~0%, which places the whole increase inside the property index rather than the graph itself. It comes from the reverse ID → (key, value) map — the thing that makes DeleteNode ~700× cheaper — plus sixteen-way sharding paying map overhead sixteen times, which is what makes concurrent registration on distinct keys 2.4× faster. It is worst where values are shared and mildest where they are all distinct.

Smaller costs, all on the property path: a raw single-key lookup is +52–59% (≈78 ns, against what used to be a 40 ms scan) — roughly half of that is read consistency, which resolves postings against the records so a lookup cannot return an entity a concurrent delete already removed. Registering a property allocates about twice as much, again the reverse map. Reopening allocates 63% more peak memory while running 94% faster, because the index arrives as one file read instead of streaming from the log.

Ingest and point lookups are unchanged.

How much to trust these numbers

Measured as an interleaved A/B against 036aac0 after a cooldown, with the benchmark files copied into the baseline tree so both sides run identical benchmark code against different implementations.

One round of four was discarded from both sides: its samples on the new side roughly doubled, having run last under peak thermal load. And the two controls then disagreed — GetNode flat as expected, but PointLookupNode_Memory reporting −24% on byte-identical code. So timing effects below ~25% are not resolvable here; the order-of-magnitude results are solid and anything in the tens of percent is directional. The footprint table above is exempt, being deterministic measurement rather than timing.

This is the summary. benchmarks.md carries the full detail: methodology, the fixture, per-area breakdowns, the regressions in full, what is still slow, and an appendix listing every benchmark on both sides across time, bytes, allocations and per-node footprint.

Full methodology, per-benchmark detail, and the remaining slow paths are in benchmarks.md.

Scale validation covered by stress tests:

  • 100,000 nodes + 500,000 edges large-ingest scenario.
  • 50 goroutines concurrent write pressure.
  • 50,000-node property index lookup validation.
  • Optional persistent 1,000,000-node end-to-end test path.

Showcased Features

  • Full CRUD: add, get, update, and delete nodes and edges (cascade delete).
  • Transactions: Begin() commits creates, updates and deletes together — atomic and durable, including a delete's edge cascade.
  • Traversal toolkit: BFS, DFS, provenance chain, shortest path.
  • Query primitives: type lookups, property lookups, degree/connectivity checks.
  • Pattern discovery: scoped VF2-inspired subgraph matching.
  • Persistence lifecycle: open, replay, compact, reopen.
  • Visualization export: interactive HTML graph maps for quick analysis.
  • Forensic integrity (opt-in): signed commits, signed snapshot attestations, Merkle inclusion proofs you can hand to a third party, attributed redaction with content-free proof, a chain-of-custody report, and externally anchored checkpoints. See below.

Forensic Integrity

Graphene is built for evidence, so it can produce claims about what it holds that survive leaving the process: this artefact was in this snapshot, this one was deliberately removed, by whom and why, and neither statement has been altered since.

opts := disk.StrictOptions(key, ring, actorID)   // signed, verified, audited
g, _ := graphene.OpenWithOptions(dir, opts)
s, _ := g.Forensics()                            // the same store, not a copy
g.Compact()

blob, _ := s.ExportNodeProof(id)                 // hand this over
proof, _ := disk.UnmarshalProof(blob)            // recipient has no store
err := disk.VerifyExportedProof(retainedRoot, proof)

All of it is opt-in: graphene.Open keeps the historical defaults — unsigned, unverified — so the strict posture is something you ask for. The machinery itself lives on disk.Store, reachable via Graph.Forensics(), which returns false on the in-memory backend. Three things worth knowing before you rely on any of it:

  • It prevents nothing. Graphene is a library in your process; anything running there can call any API and use your signing key. What the machinery does is make the result detectable to someone outside.
  • Retain a snapshot root outside the system. Every internal check compares the store against itself. Only a value you kept elsewhere distinguishes "internally consistent" from "not tampered with".
  • The engine ships no anchoring transport, by design — so until you supply one, every guarantee is the store vouching for itself.

SECURITY.md states what each mechanism proves and what it does not. docs/FORENSICS.md is the working guide, and API_REFERENCE.md §22 is the best-practice checklist — including the handful of places where the performance guide's advice and evidentiary requirements disagree, and which to prefer. go run ./examples executes the whole flow.

Mutation (update / delete)

Entities can be edited and removed, and every change is durable:

// Update replaces labels + properties in place (edge endpoints are immutable).
_ = g.UpdateNode(&store.Node{ID: artID, Labels: []store.NodeType{store.NodeTypeTag}, Properties: []byte("reclassified")})
_ = g.UpdateEdge(&store.Edge{ID: eid, Labels: []store.EdgeType{store.EdgeTypeReuse}, Weight: 0.42})

// DeleteEdge removes one relationship; DeleteNode cascades to incident edges.
_ = g.DeleteEdge(eid)
_ = g.DeleteNode(artID)

On the disk backend, updates and deletes are written to the WAL (deletes as tombstone records) and take effect immediately for reads; the freed space is reclaimed at the next Compact(). IDs are monotonic and never reused. See the API reference for full semantics.

Query Model

GrapheneDB is API-first. It does not use a SQL-like or string-based query language.

Query behavior is exposed as typed Go functions on Graph and GraphStore, for example:

  • Node and edge retrieval by indexed properties.
  • Multi-property matching through function parameters.
  • Typed query functions for nodes, edges, and relations.
  • Traversal and pattern functions for graph-structured analysis.
  • Built-in deterministic ordering with offset/limit pagination.
  • Query plans via ExplainNodeQuery / ExplainEdgeQuery.
  • Sort direction control with Order: store.QueryOrderAsc|QueryOrderDesc.
  • Custom type selectors for user-defined labels (for example custom:7).

This keeps query behavior explicit, type-safe, and easy to compose inside Go code.

ids, _ := g.QueryNodeIDs(store.NodeQuery{
  Types:  []store.NodeType{store.NodeTypeMicroArtefact},
  Filters: []store.PropertyFilter{
    {Key: "bucket", Op: store.PropertyOpPrefix, Value: []byte("bucket-0")},
  },
  Order:  store.QueryOrderDesc,
  Offset: 0,
  Limit:  50,
})

Indexing

Queries are served from indexes, not from scans. What exists today:

Index Backing structure Serves
Primary (ID → record) Hash map in memory; direct array offset in the CSR GetNode, GetEdge
Adjacency CSR prefix-sum arrays, plus a delta overlay Neighbours, traversal, anchored relations, degree
Label (type) Postings per label, built for both the delta and CSR NodesByType, EdgesByType, Types filters
Property (secondary) Sorted postings per (key, value) + reverse ID map Equality filters, NodesByProperty
Ordered (range) Sorted values per declared key, ascending postings >, >=, <, <=, Between, Prefix

The query planner picks whichever of these bounds the result most tightly — property postings, a declared ordered key's range, label postings, or the anchors' incident-edge lists — and falls back to a full scan only when none applies. Property indexing stays explicit and opt-in: you register the fields you want indexed with IndexNodeProperty / IndexNodeProperties, so the storage layer never has to understand your property encoding.

Index-accelerated operators: PropertyOpEqual always; the range operators and Prefix once the key is declared ordered (below). Contains is a scan and will stay one — no ordering can bound a substring match.

A filter that cannot be served no longer costs a scan of its whole key. Only one filter drives a query; the rest are applied afterwards, and each is costed both ways — probe the candidates through the index's reverse map, or resolve the filter to its own set and intersect. A query driven down to a single candidate used to scan every entry under a Contains key to eliminate it, which is what made the pairing 12.97 ms; probing that one candidate instead makes it 443 ns.

You can see what the planner decided:

plan, _ := g.ExplainNodeQuery(q)
fmt.Println(plan)
// driver=equality(sha256) candidates=1 residual=tool:probe~100000 results=1

ExplainNodeQuery and ExplainEdgeQuery exist because results alone cannot tell an index lookup from a full scan that happened to agree with it. The plan is diagnostic — which index gets picked may change between versions; the results a query returns may not.

Ordered keys for range queries

Declaring a key builds a sorted structure over its values, turning a range filter into two binary searches:

import "github.com/aoiflux/graphene/index/encoding"

g.IndexNodeProperty(id, "score", encoding.Int64(score))
g.DeclareOrderedProperty("score")            // absorbs entries already indexed

g.QueryNodes(store.NodeQuery{Filters: []store.PropertyFilter{{
    Key: "score", Op: store.PropertyOpBetweenInclusive,
    Value: encoding.Int64(100), ValueUpper: encoding.Int64(200),
}}})

Declaring changes how that key compares. Undeclared keys use the scan rule — numeric when both sides parse, byte order otherwise — which is fine value by value but is not a valid sort order: under it "9" < "10" < "1x" < "9", a cycle. A declared key is compared byte-wise throughout, so encode values with index/encoding (or use a naturally ordered form such as zero-padded fixed-width digits or hex). Equality lookups are unaffected either way.

Measured on 1,000 distinct values: a wide range goes 22.84 ms → 2.310 ms, and a narrow one 11.76 ms → 59 µs.

Durability

The property index is stored inside the CSR file (format v6), so a compacted store reopens without replaying anything: the WAL is left empty by Compact() and restart cost no longer grows with the number of indexed entries. Files written by earlier versions (v2–v5) still open, with the WAL supplying the index as before, and are upgraded on the next Compact().

What a read guarantees

Every operation is atomic on its own. A completed DeleteNode leaves no dangling edge and no index entry, in any index, under any key. Reads give you:

every ID returned named an entity that was live at the moment it was checked.

The moment is inside the call, not after it. By the time you act on a result the entity may be gone, so GetNode on an ID you were just handed can legitimately fail — measured at 0.7% of IDs from a single-key lookup and 4–11% from a typed query, against a deleter running flat out. Treat a result set as candidates. Closing that gap needs snapshot isolation, which Graphene does not offer — Begin() gives a multi-write transaction that is atomic and durable, but not isolated, so read-decide-write across a concurrent writer still needs your own serialisation.

Holding that line needed one fix worth naming: property lookups consulted the index without consulting the records, and the two are separate structures under separate locks — so a lookup could return an entity a concurrent delete had already removed from the records. Postings are now resolved against the records. graphene_consistency_test.go asserts this under concurrent mutation, and distinguishes a genuinely torn read from the benign race above; conflating the two is what made its first version report 82 failures that were not bugs.

Keeping the index truthful

The engine cannot re-derive property-index entries on its own: indexed values are supplied by you in your own encoding, and the Properties blob is opaque to the storage layer. So updating an indexed entity needs a choice, and the API makes it explicit rather than silent:

// Preferred: update and re-register in one call. No stale entry for the old
// value, no lost entry for the untouched ones.
_ = g.UpdateNodeIndexed(
    &store.Node{ID: artID, Labels: []store.NodeType{store.NodeTypeTag}},
    map[string][]byte{"sha256": newHash},
)

// Or pick a policy for plain UpdateNode / UpdateEdge:
//   ReindexKeep  (default) — entries are kept, and therefore go stale
//   ReindexPurge           — entries are dropped, including still-valid ones
g.SetReindexPolicy(store.ReindexPurge)

Two maintenance calls back this up. g.VerifyIndexes() cross-checks every index against the records it describes — postings ordering, reverse-map agreement, label postings, adjacency endpoints, and orphaned entries — and g.RebuildIndexes() recomputes everything derivable from the records. Neither runs automatically on Open: verification is O(V+E) (~200 ms on a 100k-node store) and a damaged index section is already rejected while parsing, so the scan would be a startup tax for little gain. Run them explicitly in tests, in CI, or when recovering a suspect store.

Quick Start

package main

import (
    "fmt"

    "graphene"
    "graphene/store"
)

func main() {
    g := graphene.NewInMemory()

    a, _ := g.AddNode(&store.Node{Labels: []store.NodeType{store.NodeTypeEvidenceFile}})
    b, _ := g.AddNode(&store.Node{Labels: []store.NodeType{store.NodeTypeMicroArtefact}})
    _, _ = g.AddEdge(&store.Edge{Src: a, Dst: b, Labels: []store.EdgeType{store.EdgeTypeContains}})

    walk, _ := g.BFS(a, 2, store.DirectionOutbound, nil)
    fmt.Println("visited nodes:", len(walk.Nodes))
}

Run It

go run ./examples
./test.ps1
./test.ps1 -Bench

Docs

  • Release notes: v0.4.0 — the forensic release: what is new, what changed on disk, and what is still absent
  • Easy usage guide: USER_GUIDE.md
  • Complete API reference: API_REFERENCE.md
  • Deep technical architecture and LLD: TECHNICAL_DETAILS.md
  • Benchmark methodology and results: benchmarks.md
  • Engine comparison notes: comparison.md
  • Security model, guarantees, and their limits: SECURITY.md — read this before relying on the integrity machinery for anything evidentiary. It states what digests, Merkle roots, signatures, and attestations actually prove, and what they do not.
  • Using the integrity machinery: FORENSICS.md — the working guide. Signing, inclusion proofs, exporting a proof to a third party, lawful redaction, chain of custody, and anchoring, in the order you adopt them. Runnable form: examples/forensic_examples.go.

Query Migration

Legacy property helpers remain supported:

  • NodesByProperty, EdgesByProperty
  • NodesByProperties, EdgesByProperties

Preferred new typed APIs for new code:

  • QueryNodeIDs / QueryNodes
  • QueryEdgeIDs / QueryEdges
  • QueryRelationIDs / QueryRelations

Migration approach:

  1. Keep existing property-index calls (IndexNodeProperty, IndexEdgeProperty).
  2. Move single/multi-property lookups into typed query filters.
  3. Add explicit Order, Offset, and Limit where paged output is required.

Project Layout

  • graphene.go and helpers.go: public API surface.
  • memory/ and disk/: storage backends.
  • index/: property index (sorted postings + reverse map), ordered range index, and index/encoding order-preserving value encoders.
  • traversal/: graph traversal and pattern matching.
  • viz/: interactive HTML export.

Current Fit

GrapheneDB is best used today for exploration, prototyping, and controlled internal workloads where you want a native embeddable graph engine and can tolerate ongoing validation work.

Documentation

Overview

Package graphene is an application-specific graph storage engine designed for Indicer's forensic micro-artefact platform. It provides:

  • A pluggable GraphStore interface (store.GraphStore)
  • An in-memory reference implementation (memory.Store)
  • An on-disk, bulk-ingest-optimised CSR implementation (disk.Store)
  • Core traversal algorithms: BFS, DFS, bidirectional-BFS shortest path, and VF2-inspired subgraph pattern matching
  • Secondary indexes: type index, temporal index, and property index

Quick start

// In-memory (development / small cases)
g := graphene.NewInMemory()

// On-disk (production)
g, err := graphene.Open("/data/cases/case01")

// Add artefacts
caseID, _ := g.AddNode(&store.Node{Labels: []store.NodeType{store.NodeTypeCase}})
fileID, _ := g.AddNode(&store.Node{Labels: []store.NodeType{store.NodeTypeEvidenceFile}})
artID, _ := g.AddNode(&store.Node{Labels: []store.NodeType{store.NodeTypeMicroArtefact}})
g.AddEdge(&store.Edge{Src: fileID, Dst: artID, Labels: []store.EdgeType{store.EdgeTypeContains}})
g.AddEdge(&store.Edge{Src: fileID, Dst: caseID, Labels: []store.EdgeType{store.EdgeTypeBelongsTo}})

// Index a decoded property value for fast lookup
g.IndexNodeProperty(artID, "sha256", []byte("d4e5f6..."))
hits, _ := g.NodesByProperty("sha256", []byte("d4e5f6..."))

// Modify or remove entities (durable; edge endpoints are immutable,
// DeleteNode cascades to incident edges)
g.UpdateEdge(&store.Edge{ID: eid, Labels: []store.EdgeType{store.EdgeTypeReuse}, Weight: 0.4})
g.DeleteNode(artID)

// k-hop neighbourhood
result, _ := g.BFS(artID, 2, store.DirectionBoth, nil)

// Provenance chain back to evidence file
chain, _ := g.ProvenanceChain(artID, 10, []store.EdgeType{store.EdgeTypeContains})

// Shortest path
path, _ := g.ShortestPath(artID, caseID, nil)

Index

Constants

This section is empty.

Variables

View Source
var ErrTxDone = errors.New("graphene: transaction already finished")

ErrTxDone is returned by any method called on a transaction that has already been committed or rolled back.

Functions

func EdgesFromBFS

func EdgesFromBFS(r *traversal.BFSResult) []*store.Edge

EdgesFromBFS returns the slice of edges from a BFS result. Nil-safe.

func FilterEdgesByLabel

func FilterEdgesByLabel(es []*store.Edge, label store.EdgeType) []*store.Edge

FilterEdgesByLabel returns only the edges from es that carry the given label.

func FilterNodesByLabel

func FilterNodesByLabel(ns []*store.Node, label store.NodeType) []*store.Node

FilterNodesByLabel returns only the nodes from ns that carry the given label.

func NodeIDsFromBFS

func NodeIDsFromBFS(r *traversal.BFSResult) []store.NodeID

NodeIDsFromBFS returns the node IDs from a BFS result for use as scope in follow-up queries (e.g. FindPatterns).

func NodeIDsFromPath

func NodeIDsFromPath(r *traversal.PathResult) []store.NodeID

NodeIDsFromPath returns the ordered node IDs from a PathResult.

func NodesFromBFS

func NodesFromBFS(r *traversal.BFSResult) []*store.Node

NodesFromBFS returns the slice of nodes from a BFS result. Nil-safe.

Types

type Graph

type Graph struct {
	store.GraphStore
}

Graph wraps a GraphStore and exposes the traversal API in one place. This is the primary entry point for Indicer consumers.

func NewInMemory

func NewInMemory() *Graph

NewInMemory returns a Graph backed by the in-memory store. Suitable for development, testing, and small investigations.

func Open

func Open(dir string) (*Graph, error)

Open returns a Graph backed by the on-disk CSR store rooted at dir. dir is created if it does not exist. On restart, the WAL is replayed automatically. Call Graph.Compact() after bulk ingest to rebuild the CSR and free WAL space.

func OpenWithOptions

func OpenWithOptions(dir string, opts disk.Options) (*Graph, error)

OpenWithOptions returns a Graph backed by a disk store opened with opts.

Open gives you the historical defaults: unsigned commits, no verification on open, no audit log. That is the right default for a graph database and the wrong one for a store holding evidence, and there was previously no way to ask for the other posture without bypassing this package entirely.

key, pub, _ := signing.GenerateKey(1)
ring := signing.NewKeyring()
ring.Add(1, pub)

opts := disk.StrictOptions(key, ring, operatorActorID)
opts.Retention = disk.RetentionPolicy{MaxSegments: 50}
opts.Redaction = true
g, err := graphene.OpenWithOptions(dir, opts)

See docs/API_REFERENCE.md §22 for which options an evidentiary deployment wants and why.

func (*Graph) AddEdges

func (g *Graph) AddEdges(edges []*store.Edge) ([]store.EdgeID, error)

AddEdges adds multiple edges in order, returning their assigned IDs.

On both bundled backends this is atomic, and every endpoint is validated before anything is written — so a batch containing one dangling edge adds nothing and returns ErrInvalidEdge. Third-party stores without the batch interface fall back to a non-atomic per-edge loop.

Endpoints must already exist. To create nodes and the edges between them together, use Begin.

func (*Graph) AddNodes

func (g *Graph) AddNodes(nodes []*store.Node) ([]store.NodeID, error)

AddNodes adds multiple nodes in order, returning their assigned IDs.

On both bundled backends this is atomic: either every node is added or none is, and a failure returns a nil ID slice rather than a partial one. Third-party stores that do not implement the batch interface fall back to a per-node loop, which is not atomic — it returns the IDs assigned so far alongside the error.

For a batch of nodes *and* the edges between them, use Begin: AddNodes followed by AddEdges is two transactions, and a crash between them leaves the nodes without their edges.

func (*Graph) BFS

func (g *Graph) BFS(origin store.NodeID, maxDepth int, dir store.Direction, edgeTypes []store.EdgeType) (*traversal.BFSResult, error)

BFS performs a breadth-first traversal from origin up to maxDepth hops. Pass nil edgeTypes to follow all edge types.

func (*Graph) BFSIDs

func (g *Graph) BFSIDs(origin store.NodeID, maxDepth int, dir store.Direction, edgeTypes []store.EdgeType) ([]store.NodeID, error)

BFSIDs performs the same walk as BFS but returns only the reachable node IDs, in discovery order, starting with origin.

It never materialises a node or edge record, so on the bundled backends the whole traversal allocates only its visited set and result slice, no matter how many edges it crosses. Prefer it over BFS whenever the records are not needed: reachability checks, scoping a pattern match, or feeding IDs into a query.

func (*Graph) Begin

func (g *Graph) Begin() *Tx

Begin starts a transaction.

If the backend does not implement store.Transactor, the transaction still works but commits by replaying the buffered writes through the batch APIs, which is *not* atomic across the node/edge boundary. Callers who need the guarantee can check Atomic.

func (*Graph) Compact

func (g *Graph) Compact() error

Compact is available when the Graph is backed by a disk.Store. It merges the delta layer into the CSR and truncates the WAL. Call it after a bulk ingest is complete.

func (*Graph) DFS

func (g *Graph) DFS(origin store.NodeID, maxDepth int, dir store.Direction, edgeTypes []store.EdgeType) (*traversal.BFSResult, error)

DFS performs a depth-first traversal from origin up to maxDepth hops.

func (*Graph) DeclareOrderedEdgeProperty

func (g *Graph) DeclareOrderedEdgeProperty(key string) error

DeclareOrderedEdgeProperty is DeclareOrderedProperty for edge properties.

func (*Graph) DeclareOrderedProperty

func (g *Graph) DeclareOrderedProperty(key string) error

DeclareOrderedProperty builds and maintains an ordered index over a node property key, so that range filters (`>`, `>=`, `<`, `<=`, `Between`) and `Prefix` on that key are answered by binary search instead of by scanning every entry registered under it. Entries already present are absorbed, so this can be called at any point.

**Declaring a key changes how its range predicates compare.** Undeclared keys use the scan-path rule: try numeric comparison, fall back to byte order. That rule is fine value-by-value but is not a valid sort order — "9" < "10" < "1x" < "9" under it — so no ordered structure can be built on it. A declared key is compared byte-wise throughout. Encode values so byte order matches your intent:

// zero-padded fixed width, or index/encoding for real numbers
g.IndexNodeProperty(id, "score", encoding.Int64(score))
g.DeclareOrderedProperty("score")

g.QueryNodes(store.NodeQuery{Filters: []store.PropertyFilter{{
    Key: "score", Op: store.PropertyOpBetweenInclusive,
    Value: encoding.Int64(100), ValueUpper: encoding.Int64(200),
}}})

Equality lookups are unaffected. Backends without the extension ignore this and keep scanning.

func (*Graph) Degree

func (g *Graph) Degree(id store.NodeID, edgeTypes []store.EdgeType) (int, error)

Degree returns the total (in + out) edge count for node id. Pass nil edgeTypes to count all edges. Note that for undirected use-cases, edges that appear in both directions are counted twice.

func (*Graph) EdgeExists

func (g *Graph) EdgeExists(src, dst store.NodeID, edgeTypes []store.EdgeType) (bool, error)

EdgeExists reports whether at least one direct edge exists from src to dst. Pass nil edgeTypes to consider edges of any type.

func (*Graph) EdgesByAnyType

func (g *Graph) EdgesByAnyType(types []store.EdgeType) ([]store.EdgeID, error)

EdgesByAnyType returns all EdgeIDs that carry at least one of the given labels (OR semantics). Duplicate IDs are deduplicated.

func (*Graph) EdgesByAnyTypeSelector

func (g *Graph) EdgesByAnyTypeSelector(selectors []string) ([]store.EdgeID, error)

EdgesByAnyTypeSelector returns all EdgeIDs matching at least one selector.

func (*Graph) EdgesByProperties

func (g *Graph) EdgesByProperties(props map[string][]byte) ([]store.EdgeID, error)

EdgesByProperties returns the intersection of all EdgeIDs that match every key-value pair in props (AND semantics). Returns an empty slice when props is empty.

func (*Graph) EdgesByTypeSelector

func (g *Graph) EdgesByTypeSelector(selector string) ([]store.EdgeID, error)

EdgesByTypeSelector parses selector and returns matching edge IDs. Supports built-in names and custom selectors such as "custom:7".

func (*Graph) EdgesWithProperties

func (g *Graph) EdgesWithProperties(props map[string][]byte) ([]*store.Edge, error)

EdgesWithProperties returns hydrated edges matching all key-value pairs.

func (*Graph) ExplainEdgeQuery

func (g *Graph) ExplainEdgeQuery(q store.EdgeQuery) (store.QueryPlan, error)

ExplainEdgeQuery reports how the planner resolves q. See ExplainNodeQuery.

func (*Graph) ExplainNodeQuery

func (g *Graph) ExplainNodeQuery(q store.NodeQuery) (store.QueryPlan, error)

ExplainNodeQuery reports how the planner resolves q: which index drove it, how many candidates that produced, and how each remaining filter was applied.

This is how planner behaviour gets verified. A query can return the right answer while doing far more work than it needed to, and the difference is invisible from the results alone — a test that asserts only on results cannot tell an index lookup from a full scan that happened to agree with it.

The plan is diagnostic output. Which index the planner picks may change as the cost model improves; the results a query returns may not.

func (*Graph) FindPatterns

func (g *Graph) FindPatterns(pattern *traversal.Pattern, scope []store.NodeID, maxMatches int) ([]traversal.SubgraphMatch, error)

FindPatterns searches for all subgraphs matching pattern within scope. scope limits the candidate nodes; pass nil to search all nodes of the matching type (expensive on large graphs — prefer scoping to a case BFS result). maxMatches caps output; pass 0 for no cap.

func (*Graph) Forensics

func (g *Graph) Forensics() (*disk.Store, bool)

Forensics returns the disk store behind this Graph, and whether there is one.

The integrity machinery — signed commits, snapshot roots, inclusion proofs, attributed redaction, chain of custody, checkpoints and anchoring — lives on disk.Store rather than here, and this is the supported way to reach it.

Why an accessor and not forty methods

Forwarding each call would double an API surface of about fifty symbols, and every one of them would be a place for the façade's version to drift from the engine's. It would also flatten a distinction worth keeping: none of that machinery works on the in-memory backend, so a Graph that cannot do it should say so once rather than fail fifty times. Returning false is that answer.

if s, ok := g.Forensics(); ok {
    proof, err := s.ProveNode(id)
}

The store is the same one the Graph is using, not a copy — calls through it are visible to the Graph immediately, and closing the Graph closes it.

See SECURITY.md for what each mechanism proves and does not, and docs/FORENSICS.md for how to use them.

func (*Graph) GetEdges

func (g *Graph) GetEdges(ids []store.EdgeID) (found []*store.Edge, missing []store.EdgeID, err error)

GetEdges fetches multiple edges by ID in the order given. If any ID is not found the error is returned immediately. GetEdges fetches multiple edges by ID, preserving request order. A missing ID is reported in missing rather than returned as an error — see GetNodes.

func (*Graph) GetNodes

func (g *Graph) GetNodes(ids []store.NodeID) (found []*store.Node, missing []store.NodeID, err error)

GetNodes fetches multiple nodes by ID in the order given. If any ID is not found the error is returned immediately. GetNodes fetches multiple nodes by ID, preserving request order.

**A missing ID is not an error.** It is reported in missing, and err is reserved for genuine failures. That is deliberate: under the read model (API_REFERENCE §16) an ID can be deleted between the call that produced it and the call that resolves it, so treating that as exceptional forced callers back into the per-item loop this method exists to replace.

found is compacted — missing IDs leave no nil holes — and each record carries its own ID, so a caller needing to correlate results back to requested IDs can read node.ID rather than relying on position.

func (*Graph) HandleSignals

func (g *Graph) HandleSignals(signals ...os.Signal) func()

HandleSignals registers a graceful shutdown hook that closes the graph when any of the provided signals is received. If no signals are provided, a platform-appropriate default set is used.

The returned stop function unregisters the signal handler.

func (*Graph) HasCycle

func (g *Graph) HasCycle(origin store.NodeID, maxDepth int, edgeTypes []store.EdgeType) (bool, error)

HasCycle reports whether any cycle is reachable from origin within maxDepth hops following outbound edges. It uses DFS and detects back-edges in the recursion stack. Pass nil edgeTypes to follow all edge types.

func (*Graph) InDegree

func (g *Graph) InDegree(id store.NodeID, edgeTypes []store.EdgeType) (int, error)

InDegree returns the number of inbound edges for node id. Pass nil edgeTypes to count all inbound edges.

func (*Graph) IndexEdgeProperties

func (g *Graph) IndexEdgeProperties(id store.EdgeID, props map[string][]byte) error

IndexEdgeProperties indexes all key-value pairs in props for the given edge. Indexing stops and the error is returned on first failure.

func (*Graph) IndexNodeProperties

func (g *Graph) IndexNodeProperties(id store.NodeID, props map[string][]byte) error

IndexNodeProperties indexes all key-value pairs in props for the given node. Indexing stops and the error is returned on first failure.

func (*Graph) InducedSubgraph

func (g *Graph) InducedSubgraph(nodeIDs []store.NodeID) ([]*store.Node, []*store.Edge, error)

InducedSubgraph returns the nodes and all edges between them for the given set of node IDs. The result edges are those whose Src AND Dst are both in the provided set.

func (*Graph) IsConnected

func (g *Graph) IsConnected(src, dst store.NodeID) (bool, error)

IsConnected reports whether src and dst are reachable from one another via any sequence of edges. It uses the shortest-path algorithm internally and considers all edge types.

func (*Graph) NeighboursByNodeType

func (g *Graph) NeighboursByNodeType(id store.NodeID, dir store.Direction, nodeType store.NodeType, edgeTypes []store.EdgeType) ([]*store.Node, error)

NeighboursByNodeType returns all directly connected nodes of a specific NodeType, optionally filtered by edge types. Pass nil edgeTypes to follow all edge types.

func (*Graph) NodesByAnyType

func (g *Graph) NodesByAnyType(types []store.NodeType) ([]store.NodeID, error)

NodesByAnyType returns all NodeIDs that carry at least one of the given labels (OR semantics). Duplicate IDs are deduplicated.

func (*Graph) NodesByAnyTypeSelector

func (g *Graph) NodesByAnyTypeSelector(selectors []string) ([]store.NodeID, error)

NodesByAnyTypeSelector returns all NodeIDs matching at least one selector.

func (*Graph) NodesByProperties

func (g *Graph) NodesByProperties(props map[string][]byte) ([]store.NodeID, error)

NodesByProperties returns the intersection of all NodeIDs that match every key-value pair in props (AND semantics). Returns an empty slice when props is empty.

func (*Graph) NodesByTypeSelector

func (g *Graph) NodesByTypeSelector(selector string) ([]store.NodeID, error)

NodesByTypeSelector parses selector and returns matching node IDs. Supports built-in names and custom selectors such as "custom:7".

func (*Graph) NodesWithProperties

func (g *Graph) NodesWithProperties(props map[string][]byte) ([]*store.Node, error)

NodesWithProperties returns hydrated nodes matching all key-value pairs.

func (*Graph) OrderedProperties

func (g *Graph) OrderedProperties() (nodeKeys, edgeKeys []string)

OrderedProperties returns the node and edge property keys currently backed by an ordered index, each sorted.

func (*Graph) OutDegree

func (g *Graph) OutDegree(id store.NodeID, edgeTypes []store.EdgeType) (int, error)

OutDegree returns the number of outbound edges for node id. Pass nil edgeTypes to count all outbound edges.

func (*Graph) ProvenanceChain

func (g *Graph) ProvenanceChain(origin store.NodeID, maxDepth int, edgeTypes []store.EdgeType) (*traversal.DFSResult, error)

ProvenanceChain walks inbound edges from origin back to the root evidence source (e.g. the EvidenceFile node), following the given edge types. Pass nil edgeTypes to follow all inbound edges.

func (*Graph) QueryEdgeIDs

func (g *Graph) QueryEdgeIDs(query store.EdgeQuery) ([]store.EdgeID, error)

QueryEdgeIDs returns edge IDs that satisfy query constraints.

func (*Graph) QueryEdges

func (g *Graph) QueryEdges(query store.EdgeQuery) ([]*store.Edge, error)

QueryEdges returns hydrated edges that satisfy query constraints.

func (*Graph) QueryNodeIDs

func (g *Graph) QueryNodeIDs(query store.NodeQuery) ([]store.NodeID, error)

QueryNodeIDs returns node IDs that satisfy query constraints.

func (*Graph) QueryNodes

func (g *Graph) QueryNodes(query store.NodeQuery) ([]*store.Node, error)

QueryNodes returns hydrated nodes that satisfy query constraints.

func (*Graph) QueryRelationIDs

func (g *Graph) QueryRelationIDs(query store.RelationQuery) ([]store.EdgeID, error)

QueryRelations returns relation edges around anchor nodes using direction-aware matching.

func (*Graph) QueryRelations

func (g *Graph) QueryRelations(query store.RelationQuery) ([]*store.Edge, error)

QueryRelations returns relation edges around anchor nodes using direction-aware matching.

func (*Graph) RebuildIndexes

func (g *Graph) RebuildIndexes() error

RebuildIndexes discards and recomputes every index derivable from the stored records — label postings and adjacency — and drops property-index entries whose entity no longer exists. Backends that do not support it return nil.

It repairs structure, not content: property-index *values* are supplied by the caller and cannot be recovered from the records, so entries for live entities are left as they are. The disk backend runs this automatically on Open when its own verification fails, so calling it by hand is normally unnecessary.

func (*Graph) ReindexPolicy

func (g *Graph) ReindexPolicy() store.ReindexPolicy

ReindexPolicy returns the configured policy, or store.ReindexKeep if the backend does not support configuring one.

func (*Graph) SetReindexPolicy

func (g *Graph) SetReindexPolicy(p store.ReindexPolicy)

SetReindexPolicy controls what UpdateNode / UpdateEdge do to the property index. See store.ReindexPolicy for the trade-off between the two modes; the default (store.ReindexKeep) preserves historical behaviour.

Prefer UpdateNodeIndexed / UpdateEdgeIndexed over either policy where you can: they update and re-register in one step, so the index is never stale and never silently loses entries.

func (*Graph) ShortestPath

func (g *Graph) ShortestPath(src, dst store.NodeID, edgeTypes []store.EdgeType) (*traversal.PathResult, error)

ShortestPath finds the shortest undirected path between src and dst using bidirectional BFS.

func (*Graph) ShouldCompact

func (g *Graph) ShouldCompact(p store.CompactionPolicy) (bool, string)

ShouldCompact reports whether the store has breached policy, and which rule fired.

Advisory only. Nothing in the engine acts on it, and calling it changes nothing — compaction rebuilds the entire image, so when to pay that is the caller's decision, not the engine's. A backend that cannot report its storage state returns false.

The intended shape is a periodic check in the caller's own loop:

if due, why := g.ShouldCompact(store.DefaultCompactionPolicy()); due {
    log.Printf("compacting: %s", why)
    g.Compact()
}

This exists because nothing else bounds delta growth. Everything written since the last compaction stays in memory and is replayed at every open, so a store that is never compacted degrades in memory, open time, and read speed at once — with no error and no warning until someone measures it.

func (*Graph) Stats

func (g *Graph) Stats() (*GraphStats, error)

Stats returns high-level counts for the graph, and storage detail where the backend can supply it.

func (*Graph) StorageStats

func (g *Graph) StorageStats() (store.StorageStats, bool)

StorageStats reports the backend's storage state, and whether it could.

Cheaper than Stats when only the operational figures are wanted: it does not count nodes and edges, which on the disk backend means it does not merge the CSR and delta views.

func (*Graph) Sync

func (g *Graph) Sync() error

Sync forces everything written so far to durable storage, returning once it survives power loss.

This matters because individual writes are *not* synced as they happen: an fsync per AddNode would turn a ~6 µs operation into a ~1 ms one. Batch commits sync by default; single writes rely on this, on Compact, or on Close.

On a backend without durability — the in-memory store — this is a no-op and returns nil.

func (*Graph) UpdateEdgeIndexed

func (g *Graph) UpdateEdgeIndexed(e *store.Edge, props map[string][]byte) error

UpdateEdgeIndexed updates an edge and replaces its property-index entries in one step. See UpdateNodeIndexed.

func (*Graph) UpdateNodeIndexed

func (g *Graph) UpdateNodeIndexed(n *store.Node, props map[string][]byte) error

UpdateNodeIndexed updates a node and replaces its property-index entries in one step: every entry previously registered for the node is dropped and props is registered in its place.

This is the correct way to edit a node whose properties are indexed. Plain UpdateNode cannot maintain the index — the engine does not know how to decode your Properties blob — so it either leaves stale entries behind or (under store.ReindexPurge) drops entries that were still valid. Passing the full desired index state here avoids both.

Pass a nil or empty props map to update the node and leave it un-indexed.

func (*Graph) VerifyIndexes

func (g *Graph) VerifyIndexes() error

VerifyIndexes cross-checks every index against the records it describes and returns the first inconsistency found, or nil if they all agree. Both bundled backends support it; a backend that does not returns nil.

It validates structure — postings ordering, reverse-map agreement, adjacency endpoints, and that no index entry outlives its entity. It cannot validate that an indexed *value* still matches the entity's properties: those values are caller-encoded and opaque to the engine. See SetReindexPolicy.

Intended for tests, for CI, and after recovering a store whose indexes may have been rebuilt from a partial log.

type GraphStats

type GraphStats struct {
	NodeCount uint64
	EdgeCount uint64

	// Storage describes what the backend is holding — delta size, log size, and
	// when it last compacted. Valid only when HasStorage is true; backends
	// without a delta layer or a log have nothing to report.
	Storage    store.StorageStats
	HasStorage bool
}

GraphStats holds high-level statistics about the graph.

type Tx

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

Tx is a set of writes that commit together or not at all.

A Tx is **not** safe for concurrent use by multiple goroutines. It is a caller-side buffer; the store lock is taken once, at Commit.

Writes are buffered in memory until Commit, so a transaction costs memory proportional to its size. That is the same trade the slice APIs make, but it means a single enormous transaction is not free — for bulk loads that do not need whole-file atomicity, commit in chunks.

func (*Tx) AddEdge

func (tx *Tx) AddEdge(e *store.Edge) store.EdgeID

AddEdge buffers an edge and returns the ID it will have once committed.

Src and Dst may name nodes that already exist or nodes added earlier in this same transaction. Endpoints are validated at Commit, under the store lock — validating here would be racy, because a node can be deleted between buffering and committing.

func (*Tx) AddNode

func (tx *Tx) AddNode(n *store.Node) store.NodeID

AddNode buffers a node and returns the ID it will have once committed.

The returned ID is usable immediately as an edge endpoint within this transaction. It is reserved, not created: if the transaction is rolled back or fails, the ID is never used by anything.

The node is copied, so the caller may reuse its slices as soon as this returns — the same contract as AddNode.

func (*Tx) As

func (tx *Tx) As(ctx store.TxContext) *Tx

As records who is making this transaction. It returns tx so it can be chained onto Begin.

The actor is written into the commit record alongside the commit's sequence number and wall-clock time, which makes the change attributable when the log is read back. It is recorded, not verified — see store.TxContext. Attribution is per-transaction because that is the unit the log can record it against: writes made through the plain APIs outside a transaction produce no commit record and are therefore unattributed.

Attributed reports whether the actor will actually be durable, which is false on backends that keep no log.

func (*Tx) Atomic

func (tx *Tx) Atomic() bool

Atomic reports whether Commit is all-or-nothing on this backend. It is false only for third-party stores that do not implement store.Transactor; both bundled backends return true.

func (*Tx) Attributed

func (tx *Tx) Attributed() bool

Attributed reports whether this transaction's actor will be recorded durably on commit. It is false when no actor has been set, and false on a backend that does not implement store.ActorTransactor — the in-memory store, for instance, accepts an actor and has nowhere to keep it.

func (*Tx) Commit

func (tx *Tx) Commit() error

Commit applies every buffered operation as one unit, in the order issued.

On error nothing is applied and the store is unchanged. The transaction is finished either way: a failed Commit does not need, and does not accept, a Rollback.

func (*Tx) DeleteEdge

func (tx *Tx) DeleteEdge(id store.EdgeID)

DeleteEdge buffers an edge deletion.

func (*Tx) DeleteNode

func (tx *Tx) DeleteNode(id store.NodeID)

DeleteNode buffers a node deletion.

Deletion cascades: every edge incident to the node goes too, including edges created earlier in this same transaction. The cascade is computed at commit, under the store lock — computing it at buffer time would resolve against a graph that can still change before the transaction commits.

func (*Tx) Len

func (tx *Tx) Len() (nodes, edges int)

Len reports how many nodes and edges this transaction *creates*. It does not count updates or deletes; use Ops for the total.

func (*Tx) Ops

func (tx *Tx) Ops() int

Ops reports the total number of buffered operations.

func (*Tx) Rollback

func (tx *Tx) Rollback() error

Rollback discards the transaction. It costs nothing: nothing has been written.

Rolling back a transaction that has already finished returns ErrTxDone, so a deferred Rollback after a successful Commit is harmless but not silent — ignore its error in that idiom:

tx := g.Begin()
defer func() { _ = tx.Rollback() }()

func (*Tx) UpdateEdge

func (tx *Tx) UpdateEdge(e *store.Edge)

UpdateEdge buffers a replacement for an existing edge. Same rules as UpdateNode; both endpoints must also resolve at commit.

func (*Tx) UpdateNode

func (tx *Tx) UpdateNode(n *store.Node)

UpdateNode buffers a replacement for an existing node.

The node must exist when the transaction commits — either in the store, or created earlier in this same transaction. Labels must be non-empty. Update replaces the record wholesale, exactly as Graph.UpdateNode does.

Directories

Path Synopsis
cmd
graphene command
Command graphene inspects a Graphene store from a shell.
Command graphene inspects a Graphene store from a shell.
Package examples demonstrates common Graphene usage patterns for the Indicer forensic platform.
Package examples demonstrates common Graphene usage patterns for the Indicer forensic platform.
encoding
Package encoding provides order-preserving encodings for property values.
Package encoding provides order-preserving encodings for property values.
Package merkle implements the RFC 6962 Merkle tree used for snapshot roots and inclusion proofs.
Package merkle implements the RFC 6962 Merkle tree used for snapshot roots and inclusion proofs.
Package signing provides an Ed25519 implementation of store.Signer and store.Verifier, plus a keyring for verifying against several keys.
Package signing provides an Ed25519 implementation of store.Signer and store.Verifier, plus a keyring for verifying against several keys.

Jump to

Keyboard shortcuts

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