Hanzo

Graph

Package graph is one organization's entities and the relations between them, held as assertions: somebody, at some moment, from some evidence, asserted that this thing stands in that relation to that…

Package graph is one organization's entities and the relations between them, held as assertions: somebody, at some moment, from some evidence, asserted that this thing stands in that relation to that other thing.

Base URLhttps://api.hanzo.ai
Operations6
AuthAuthorization: Bearer $HANZO_API_KEY

Specification

HIP-1198 · Graph — The Assertion Plane — Draft · read the specification →

/v1/graph is one organization's entities and the relations between them, held as assertions: somebody, at some moment, from some evidence, asserted that this thing stands in that relation to that other thing. It is implemented in hanzoai/cloud at apps/graph.

Five operations answer today. They are served behind the flag named for the capability and are in no public document — the stage is alpha (manifest/apps.go), so no generated client, tool list, command group or public page carries them. What is proposed here is the boundary that keeps this plane and knowledge's renderer from both owning nodes and edges (§1), the retention this capability does not yet have (§7), and the engine github.com/hanzoai/graph, which is forked and not linked into the served binary (§8).

Motivation

GET /v1/knowledge/graph already returns nodes and edges, and it is public. A second capability that also stores nodes and edges is the expensive mistake available here: two shapes for one word, two stores a customer has to reconcile, and an edge that can disagree with itself depending on which address answered.

The two are not the same thing, and the difference is not size. Knowledge's graph is a projection of documents the tenant already holds — a page's parent field, a wikilink's target, a source's connector — computed at read and stored nowhere. What has no owner anywhere in the binary is the other kind of edge: the one somebody asserted. Who said so, when it became knowable, what disagreed with it, and what the answer was last Tuesday are four questions a projection cannot be asked, because the rows that would answer them were never written.

Specification

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.

§1 The boundary against knowledge

One test decides which side a responsibility falls on: is the edge already written in a record the tenant holds, or did somebody claim it?

An edge that is a projection of a record its owner already holds MUST be computed at read by that owner, and MUST NOT be asserted here as a second copy. A page's parent, a wikilink and a source's connector are all written inside the document they belong to; storing them again makes a copy free to disagree with the document, and read-time resolution is what lets a rename or a deletion need no edge rewrite. That is knowledge's half (HIP-1260), and apps/knowledge/graph.go computes it without a store of its own, degrading to an empty graph rather than a 5xx when the documents cannot be read.

An edge that is a claim cannot be computed from anything, because nothing else in the binary holds who claimed it. That is this capability's half, with the three properties a claim carries and a projection does not: provenance, a time axis, and a conflict that stays visible.

The division that follows:

responsibilityownerwhy
documents, titles, slugs, wikilink resolutionknowledgethe record is the document; the edge is a field of it
render shape — force-directed nodes, placeholders for unresolved linksknowledgea view of a document set, recomputed each read
who asserted, from what evidence, under which identitygraphnot derivable from any record
when an assertion became knowable, and reads bounded to an instantgrapha projection has one tense
disagreement between two sources about one relationgrapha projection cannot hold two answers
identity resolution — same asserted and then settledgrapha claim about two keys, not a field of either

graph MUST NOT store documents, resolve titles or slugs, or emit render coordinates. knowledge MUST NOT persist a node or edge table. Neither imports the other; nothing in hanzoai/cloud imports apps/graph except plugin/graph/main.go.

§2 The model

An entity exists because something was asserted about it. There is no node table, no create and no cascade delete — nothing to keep in step with anything. An edge is an assertion whose value names another entity; a property is an assertion whose value is a scalar. They are one thing, stored once, and names is the one bit that tells them apart, which is why a walk can read only the edges without guessing at the shape of a value.

A retraction is an assertion. There is no UPDATE statement in the package and no DELETE outside disposal.

That is the primitive because of what a reader can then do: see that something was retracted, rather than find that it is gone. The distinction is the whole product. An upsert-by-id graph answers "X has no owner" identically whether nobody ever said so, somebody said so and withdrew it, or two sources disagreed and the later write won — and it cannot be asked which afterwards, because the rows that would tell the difference were never written. Provenance is columns, point-in-time is the derived instant, and conflict is the shared order. None of them is a subsystem, and none of them can be added later to a store that overwrites.

What it costs, plainly:

  1. A read is a resolution, not a lookup. Answering what is in force about one (entity, relation) means selecting every assertion for the pair, ordering it, and returning the winner with the losers beside it. There is no single row to point at, and there is no index that makes one.
  2. The table only grows. Redelivering an identical assertion is free — it collides on its content address and is counted as a duplicate, not refused — but a correction is a row and a re-assert differing in any field is a row. §7 is the consequence.
  3. There is no referential integrity, because entities are not rows. An edge may name a key nothing was ever asserted about, and a walk will reach it. That is not an error and it is not detectable at write time.
  4. Erasure is not expressible as an assertion. A retraction withdraws a claim; it does not remove what was said. Removal is disposal, which sits outside the model with the litigation hold — and the hold is a fact about the record rather than about the world, which is why it is outside the content address.

§3 The address

Every route is under /v1/graph and every operation is typed; none is declared with prose. None of them is in the public document, by §7.

operationmethod and pathanswers
graphAssertPOST /v1/graphrecords a batch of assertions
graphReadGET /v1/graphthe assertions matching entity, relation, value and an instant
graphResolvePOST /v1/graph/resolvewhat is in force about one (entity, relation) at an instant, and what disagreed
graphNeighborsPOST /v1/graph/neighborsa bounded walk of the edges from a seed set
graphVocabularyGET /v1/graph/vocabularythe relations in use and the terms of the order

Three behaviours are part of the contract rather than of the implementation.

A batch is judged member by member. recorded + duplicate + refused equals what was sent, and the refusals come back in the order sent, so a caller redelivering five assertions does not lose four to one malformed fifth.

The walk's bound is in the answer. A traversal is capped at 10,000 nodes and the response carries both the ceiling and whether it was reached. A caller is told the walk truncated rather than handed a short answer that looks complete. The bound is not politeness: the tenant's store holds one connection, so an unbounded recursion blocks every other write for that organization for as long as it runs.

The vocabulary is emergent. The relations a tenant has asserted are the only ontology there is; nothing declares one in advance and there is no schema store. The same response publishes the terms of the precedence order, because a reader who is told a winner without the rule cannot check it.

§4 The store

One SQLite table, assertion, per organization, opened through cloud.OrgStore under the name graph, so another organization's assertions are not in the database being read and no predicate can be forgotten. Two indexes carry the two directions a walk reads: (entity, relation) outward and (value, relation) inward. The store's own sequence is AUTOINCREMENT rather than a bare rowid, because a bare rowid is reused after the top row is deleted and would restart below a cursor that had already advanced.

A walk is a recursive query over the rows where names is set, bounded by knowable <= ?. Point-in-time is therefore not a second code path: the same seeds at a past instant answer what the graph looked like then.

Bounds are asked once, at the door, on every caller-sized value — 512 bytes of entity, 128 of relation, 2048 of value (512 when it names an entity) and 512 of evidence. With those asked, count × max is the byte bound of everything below, which a count over caller-sized values would not be.

The algebra is not in this package. The derived instant, the content address, the skew bound and the total order are cloud/claim, shared with the ground-truth plane (HIP-1261), so the binary holds one answer to "somebody asserted something about a thing" rather than two that drift. This capability contributes the vocabulary, the bounds and the store, which is all a caller of that algebra is meant to contribute.

The order's adjudication weight is constant here, and that is a claim made deliberately: this plane ranks no source above another, so it asserts no weight and the order falls through to its remaining terms — later knowable, higher confidence, lower digest. It stays total and it stays reproducible.

§5 Tenant

The organization comes from the validated principal and from nothing else. No route carries an org field, and a request without a principal is refused with 403 before any store is opened. The per-org file makes a cross-tenant read unspellable rather than merely forbidden.

The filing identity — owner, or owner/user when the request carries one — is stamped server-side from the same principal and never taken from the body. It is part of the content address, so two identities asserting the identical thing are two rows, not one.

§6 Money, events, observability

The capability is free, in those words (plugin/graph/main.go, Price: cloud.Free). It meters nothing and no debit lands anywhere.

It publishes nothing on the bus. No graph.* event reaches a customer's webhooks.

Beyond the request span every route already gets, it registers no metric and writes no per-request log line. The two structured lines it writes are at mount: one recording that the plane came up, one recording that the router exposed no op registry — which is a refusal to serve routes no projection would know about, not a warning.

§7 Stage

alpha, declared in the manifest row, reached by the flag named graph.

Retention is what it is waiting on, and §2's second cost is why. The record carries a litigation hold column, no operation moves it, and disposal has no address — so a tenant can put assertions in and cannot take a class of them out. A plane that only grows and cannot be swept is not a plane a customer should be let into for good. Promotion to beta requires an operation that expresses the tenant's own disposal decision and a hold that outranks it; promotion to ga is HIP-0140 §4.

§8 Upstream

The served surface embeds no third-party engine: its store is SQLite through cloud.OrgDB, and hanzoai/cloud does not require hanzoai/graph.

The capability forks one project. github.com/hanzoai/graph derives from Dgraph (github.com/dgraph-io/dgraph, Apache-2.0), taken at 64804bd2, with the upstream notice preserved.

What survives in HEAD: eighteen packages, 99 Go files outside tests and 44 test files, 38,815 and 13,791 lines respectively. The posting-list engine (posting, 6,588), the DQL parser (dql, 5,165), the tokenizer and index system (tok, 4,086), the type system (types, 2,860), the schema layer (schema, 1,902), the UID set algebra (algo, 1,242), the posting codec (codec, 684), the lexer (lex, 500) and the build stamp (buildvars, 450), over the shared key layout (x, 7,473) and the generated wire types (protos, 7,865, of which pb.pb.go is 7,850).

What does not survive: the cluster half and the server. There is no worker, query, edgraph, graphql, raftwal, conn or ee directory, and the two package main files in the tree are a codec benchmark and the build-stamp tool, so the fork builds no server. One consequence is worth stating rather than discovering: dql parses a query into an AST that nothing reads — no file outside the package imports it — so what the tree offers today is key-addressed access, index seeks and set algebra, not traversal, filters or response shaping. There is no timestamp allocator either; a host opens the store in managed mode and supplies its own commit timestamps.

Storage is github.com/luxfi/zapdb (Apache-2.0), a Badger fork, which hanzoai/cloud already requires at v1.10.6.

One measured fact sets the order of work between the fork and any use of it here. The generated descriptor names three message dependencies from Badger's own protobuf package (protos/pb/pb.pb.go:7119, :7122, :7123) and binds that package to github.com/luxfi/zapdb/pb (:19), whose default build defines KV as a plain struct with no ProtoReflect method (pb/types_zap.go, //go:build !grpc). The file descriptor cannot resolve, and both paths marshal through it — the write at posting/list.go:976, the read at posting/mvcc.go:634. The three fields belong to Raft proposals and stream subscription, which the reduced tree does not use, so what the fields describe is already gone and the descriptor is what still names them.

Rationale

The alternative to assertions is a mutable node/edge store keyed by id. It is easier to write, faster to read, and it forecloses the four questions in §2 the moment the first upsert lands, because the losing row is not kept anywhere. A plane whose product is provenance cannot be built on a store that overwrites.

The alternative to a separate capability is putting the assertions in knowledge, beside the documents. That braids two shapes with opposite rules: knowledge's graph must never be persisted, an assertion must never be recomputed, and one store cannot enforce both. Keeping them apart is what lets a placeholder for an unresolved wikilink stay a render artifact instead of becoming a row somebody has to reconcile.

A recursive query over one indexed table is the right store for reads that are one-entity resolutions and shallow bounded walks, which is what the five operations are. The fork exists for the reads that are not — deep traversal with filters and response shaping — and §8 says what stands between it and being linked.

Security Considerations

What an attacker gets from the wrong implementation, in the order the mistakes are easy to make.

A tenant resolved from anything but the principal hands one organization's whole assertion plane to another. The tenant is a file, not a predicate: the rows are not in the database being read, so there is no query to forget a clause in.

A filing identity taken from the body turns a plane whose product is provenance into one that certifies whatever the caller typed. An attributable record whose attribution the caller chose is not attributable.

An as-of read bounded by the filer's own seen is backdating: file today, claim it was knowable a year ago, and every horizon in the plane becomes decorative. The bound must be the derived instant — the later of the filer's claim and the server clock at the write — because a guard whose only input is a caller-declared value is exactly as strong as the caller's honesty.

A missing skew bound lets one timestamp far ahead of the server clock sit in the store, never maturing and distorting every read until it does.

An unbounded walk is a denial of service against the tenant rather than a slow request: one connection per file means the recursion holds every other write for that organization. Publishing the ceiling with the answer is what keeps a truncated walk visible instead of a short read that looks complete.

An UPDATE or a DELETE resolves a contested relation into silence and makes a retraction indistinguishable from an erasure — the one distinction this capability exists to keep.

Four surfaces

SurfaceReaches this capability asCoverage
RESTgraph at its own prefix6 operations
CLIno command reaches it yet — use HTTP or an SDK
SDKno published client declares one yet — regenerating the clients is what adds them
MCPno tool names it yet — use HTTP or an SDK

Quickstart

export HANZO_API_KEY=sk-...   # console.hanzo.ai → API keys

Then the first call — a read that needs nothing but the key. GET /v1/graph, operation graphRead:

hanzo has no subcommand for this operation — the CLI serves only what cloud's live route table confirms. Use HTTP or an SDK.

Answers 200 with object — ok.

Endpoints

EndpointWhat it does
POST /v1/graph/graphqlAsk the graph in one request, traversing.
POST /v1/graph/neighborsWalk the edges from a seed set, bounded
POST /v1/graph/resolveWhat is in force about an entity as of an instant, and what disagreed
GET /v1/graph/vocabularyThe relations in use, and the rule that resolves a conflict
GET /v1/graphRead the assertions this organization has recorded
POST /v1/graphAssert what is true of an entity

All Hanzo APIs · Interactive reference

How is this guide?

On this page