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 URL | https://api.hanzo.ai |
| Operations | 6 |
| Auth | Authorization: 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:
| responsibility | owner | why |
|---|---|---|
| documents, titles, slugs, wikilink resolution | knowledge | the record is the document; the edge is a field of it |
| render shape — force-directed nodes, placeholders for unresolved links | knowledge | a view of a document set, recomputed each read |
| who asserted, from what evidence, under which identity | graph | not derivable from any record |
| when an assertion became knowable, and reads bounded to an instant | graph | a projection has one tense |
| disagreement between two sources about one relation | graph | a projection cannot hold two answers |
identity resolution — same asserted and then settled | graph | a 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:
- 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.
- 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.
- 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.
- 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.
| operation | method and path | answers |
|---|---|---|
graphAssert | POST /v1/graph | records a batch of assertions |
graphRead | GET /v1/graph | the assertions matching entity, relation, value and an instant |
graphResolve | POST /v1/graph/resolve | what is in force about one (entity, relation) at an instant, and what disagreed |
graphNeighbors | POST /v1/graph/neighbors | a bounded walk of the edges from a seed set |
graphVocabulary | GET /v1/graph/vocabulary | the 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
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | graph at its own prefix | 6 operations |
| CLI | — | no command reaches it yet — use HTTP or an SDK |
| SDK | — | no published client declares one yet — regenerating the clients is what adds them |
| MCP | — | no tool names it yet — use HTTP or an SDK |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen 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.
import { Configuration, GraphApi } from 'hanzoai';
const api = new GraphApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.graphRead();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import GraphApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = GraphApi(client).graph_read()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.GraphAPI.GraphRead(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, graph_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = graph_api::graph_read(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.GraphApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new GraphApi(client).graphRead();The method above is the one at the current release of the document. [email protected] (npm) and [email protected] (PyPI) were generated from an earlier release, where this operation carried a different id, so it spells the method differently — regenerating the clients is what makes the two agree. SDKs →
curl https://api.hanzo.ai/v1/graph \
-H "Authorization: Bearer $HANZO_API_KEY"The door declares no tool for graph — tools/list on https://api.hanzo.ai/v1/mcp names the products it does reach. Use HTTP or an SDK.
Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/graph/graphql | Ask the graph in one request, traversing. |
POST /v1/graph/neighbors | Walk the edges from a seed set, bounded |
POST /v1/graph/resolve | What is in force about an entity as of an instant, and what disagreed |
GET /v1/graph/vocabulary | The relations in use, and the rule that resolves a conflict |
GET /v1/graph | Read the assertions this organization has recorded |
POST /v1/graph | Assert what is true of an entity |
How is this guide?