HIPsHanzo Proposals
Back to HIPs
HIP-36DraftStandards TrackInterface

Answer — Budget, Policy, Audit, Search, KB and Graph in Three SDKs

Hanzo AI
Created: 2026-09-10
Requires: HIP-0040, HIP-0111, HIP-0139, HIP-0526, HIP-1030, HIP-1041, HIP-1101, HIP-1103, HIP-1126, HIP-1147, HIP-1198, HIP-1202, HIP-1220, HIP-1260

HIP-0044: Answer — Budget, Policy, Audit, Search, KB and Graph in Three SDKs

Abstract

Six groups of routes on api.hanzo.ai answer questions a program asks about itself: what it may spend, what it may do, what it did, what the org knows, what the org wrote down, and what the org holds as assertions. Every one is registered by a router in hanzoai/cloud and appears in the served document. None is reachable through the published SDKs as anything but a generated method over a raw address.

This HIP specifies a hand-written surface over the generated client in hanzoai/go-sdk, hanzoai/python-sdk and hanzoai/js-sdk: one credential path, one answer type, and six accessors named budget, policy, audit, search, kb and graph.

A method that can be refused returns Answer<T> with three arms — ok, denied, held — and a caller cannot reach the value without acknowledging which arm it got. A refused budget and a refused policy are outcomes the caller reads, not exceptions the caller catches. The fourth outcome, Fault, is reserved for the case where no decision was made at all.

It specifies no address and no server behaviour. The routes are HIP-1198's, HIP-1260's and their neighbours'; the document that describes them is HIP-1030's; the credential is HIP-0111's. This is the client half, and only that.

Motivation

HIP-0040 standardizes the SDKs over the AI surface — model calls, streaming, retry policy, error classification. That settles serialization, retries and timeouts, none of which is what is missing here.

The problem is that everything outside that surface arrives as a generated method over an address, and generated methods carry only what the served document declares. Where the document declares nothing, the generator emits nothing usable. Measured against the served document on 2026-09-08 —

curl -s https://api.hanzo.ai/v1/openapi.json \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['paths']))"
1641

— four of the routes this HIP binds declare no response schema at all (GET /v1/billing/balance, GET /v1/billing/usage, POST /v1/knowledge/import, POST /v1/framework/{doctype}), and a fifth declares neither a request body nor a response (POST /v1/authz/check). The generators therefore emit a policy method that takes nothing and returns nothing, and money reads that come back as void in Go and Python and any in TypeScript. A caller who needs the balance writes the HTTP call by hand, in every language, indefinitely.

The second problem is that the three SDKs have already drifted apart on the two things they both attempt. Measured on the working trees at 2026-09-08:

Observationgo-sdkpython-sdkjs-sdk
Files under the package not listed in .generated6 of 27039 of 27100 of 2698
Hand-written client objectno — two constructorsClient(ApiClient)none
Subject scopingAs(subject)as_(subject)absent
Hold typeResult[T], opt-in via Read()Held raised by defaultabsent
Default when a call is heldsilent 2xxraisessilent 2xx
OAuth2 client_credentials exchangeabsentabsentabsent
RFC 8707 resource parameterabsentabsentabsent
Accessor named budget, policy or kbabsentabsentabsent

Two languages disagree on what a hold does by default; the third does not know holds exist. All three take a bearer token that some other process obtained, which is a credential nobody rotates and which says nothing about who is calling. None performs the exchange HIP-0111 specifies.

Divergence between the three is the failure this document exists to prevent, and it is already present before a line of the new surface is written. A specification that leaves any of the above to an implementer's judgement will be implemented three ways again.

Specification

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

§1 Scope

§1.1 The boundary against HIP-0040

HIP-0040 is not amended, extended in place, or re-litigated. It keeps everything it owns; this HIP adds a layer above it and takes nothing away.

HIP-0040 ownsThis HIP owns
Generation of the four SDKs from the served documentThe hand-written layer above the generated client
The AI/gateway surface: completions, embeddings, images, audio, files, modelsSix accessors over budget, policy, audit, search, kb and graph routes
Transport: base URL, timeouts, retry policy, exponential backoffNothing — transport is inherited unchanged
HTTP error classification into typed exceptionsWhich HTTP responses stop being exceptions and become Answer arms
Org and team request headersSubject scoping, which replaces a caller-asserted org (§4)
Package names, registries, release cadenceNo new package; the layer ships inside the existing one
Python, TypeScript, Go and RustPython, TypeScript and Go (§13 states why Rust is out of scope)

Where the two touch — an HTTP response that HIP-0040 would classify as an exception and §6 classifies as a denied arm — this HIP is authoritative, and only for the methods §8 defines. Every other method in every SDK keeps HIP-0040's behaviour exactly.

§1.2 An accessor is not a capability

HIP-0139 defines a capability as one thing the cloud does, with one name projected nine ways, one of which is the class in every generated client. The six words in this HIP are accessors, not capabilities, and the two MUST NOT be conflated: an accessor is a client-side grouping that may span several capabilities, and a capability may be reached through more than one accessor.

AccessorCapabilities it reachesRoutes
budgetallowance (HIP-1101), commerce (HIP-1220), entitlement (HIP-1202)/v1/allowance, /v1/billing/balance, /v1/billing/usage, /v1/entitlement
policyauthz (HIP-1041)/v1/authz/check
auditaudit (HIP-1103)/v1/audit
searchsearch (HIP-1147)/v1/search
kbknowledge (HIP-1260), framework (HIP-1126)/v1/knowledge/*, /v1/framework/kb.{kind}
graphgraph (HIP-1198)/v1/graph, /v1/graph/*

The generated classes named for the capabilities remain exactly as they are. An accessor is built over them; it does not replace them and MUST NOT hide them.

§1.3 What this does not specify

  • Server behaviour, addresses and wire schemas. The graph routes are HIP-1198's; the knowledge corpus is HIP-1260's; the free-call ceiling is HIP-1101's. This HIP binds what they serve and specifies none of it.
  • The substrate under the graph. How a source becomes a document, a document becomes chunks, and a chunk becomes an assertion with the span it was read from is specified by HIP-0526 and implemented by github.com/hanzoai/semantic (MIT). The graph accessor is a client for the plane above that substrate. It restates none of it, and an implementer of this HIP needs none of it.
  • How the served document is produced. HIP-1030 governs that. §11 depends on it in one direction only: a route absent from the document is a route no SDK may bind.

§2 The client

Each SDK MUST expose exactly one client type carrying the six accessors. In TypeScript this is a new type — the package has no client object today — and it is the only new type this HIP adds to a language's public entry point; the types of §5, §8 and §9 are added to all three alike.

LanguageConstruction
Goc := hanzoai.New(hanzoai.Options{ID: id, Secret: secret})
Pythonc = hanzoai.Client(id=id, secret=secret)
TypeScriptconst c = new hanzoai.Client({ id, secret })

Five options, the same five names, each language's own casing. Every one falls back to an environment variable, so the zero-argument constructor is the normal case and MUST work.

OptionEnvironmentDefaultMeaning
idHANZO_CLIENT_ID—IAM client id
secretHANZO_CLIENT_SECRET—IAM client secret
baseHANZO_BASE_URLhttps://api.hanzo.aithe one endpoint
issuerHANZO_ISSUER_URLhttps://hanzo.idwhere IAM answers
resourceHANZO_RESOURCEthe value of baseRFC 8707 audience

Requests MUST go to base with paths under /v1/. An SDK MUST NOT emit an /api/ path segment, a v2 prefix, or a request to a third host. The issuer is the only other host any SDK contacts, and only to mint (§3).

Construction MUST NOT fail on a missing id or secret; the first call does. Go's constructor therefore returns one value, not (client, error). With no credential there is nothing to exchange, so the first call raises Fault with status 0 and a reason naming HANZO_CLIENT_ID and HANZO_CLIENT_SECRET without contacting either host — an unsigned request to base would come back a bare 403, which §6 reads as an error but which tells a reader their org was refused rather than that this process never said who it was. Every TypeScript method returns a Promise; the tables in §8 name the resolved type. Python ships a synchronous client and an asynchronous one, and both carry all six accessors with the same method names — an accessor present on one and absent from the other is the divergence this HIP exists to prevent, inside a single language.

§3 Identity

The client MUST obtain its own access token by the OAuth 2.0 client-credentials grant against IAM, as specified in HIP-0111, with the RFC 8707 resource parameter naming the API it will call:

POST {issuer}/v1/iam/oauth/token
Authorization: Basic base64(id + ":" + secret)      # client_secret_basic
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&resource={resource}

The token MUST be cached until expires_in minus 60 seconds. On a 401 from base, the client MUST re-mint once and replay the request; a second 401 is the server's answer and surfaces as an error, never as a retry loop. This one re-mint is the only respect in which a request to base behaves differently from HIP-0040's transport, and it applies to every method the client makes rather than only to §8's: a credential that has expired has expired for all of them. HIP-0040 keeps retry, backoff and timeouts unchanged.

A client MUST NOT accept a pre-existing bearer token, an API key, or a HANZO_API_KEY environment variable, and MUST NOT read one if it is set. There is no second credential path and no option that introduces one. A token handed to the SDK is a token nobody rotates and a token that does not say who is calling, which is the question every gate in the estate exists to answer.

§4 Acting as a subject

An operator credential may re-scope itself to act as one subject:

LanguageCall
Goscoped := c.As("usr_7")
Pythonscoped = c.as_("usr_7")
TypeScriptconst scoped = c.as("usr_7")

The scoped client is a distinct value; the original is unchanged. The operator credential MUST leave with the scope — two credentials on one request are two answers to who is calling, and the request would carry no single principal.

The scoped token MUST be obtained by RFC 8693 token exchange on the same token endpoint as §3, which is what HIP-0111 §7 specifies for delegation. An SDK MUST NOT reach a bespoke issue-user-token verb; HIP-0111 §7 forbids one by name, and both SDKs that implement scoping today use one (§13).

POST {issuer}/v1/iam/oauth/token
Authorization: Basic base64(id + ":" + secret)
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_subject={subject}
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&resource={resource}

The token that comes back carries the target subject and that subject's owner, plus an act claim naming the acting client. It is cached and refreshed exactly as in §3, keyed by subject.

IAM gates this grant: the acting client's owning org must be a platform signing owner and its client_id must be listed for exchange (HIP-0111 §7). An SDK MUST NOT treat a refusal here as a denied arm — it is a 401/403 on a credential operation, so it is an error per §6, and it means the calling application is not permitted to act for anyone rather than that this call was refused.

No method in §8 takes an org or a tenant, and none takes a subject it will act as. The tenant is the validated principal, everywhere. A method accepting one would be a cross-tenant read the caller asserted for itself. policy.check takes a sub (§8.2) because it asks a question about a subject rather than acting as one; its answer is scoped to the caller's own tenant like every other read.

Python spells this as_ because as is a keyword and PEP 8 prescribes the trailing underscore. The word is still as. This is the general rule of §10, and within this HIP it applies three times: here, to kb.import in §8.5, and to graph.assert in §8.6.

§5 Answer

Every method §8 marks as gated MUST return one type with three arms. A caller MUST NOT be able to reach the value without first narrowing to an arm, to the extent the language admits: TypeScript and Python make it impossible, and Go makes it an ignored error, which errcheck and golangci-lint report but the compiler permits. Go's arm is therefore enforced by convention where the other two enforce it by type. That is the language's limit, not a licence to weaken the other two.

Answer<T>
  ok      value    T
  denied  code     string      # "insufficient_balance", "spend_cap_exceeded", …
          reason   string      # the problem detail's `detail` member
          product  string?     # present when the gate is product-scoped
          cures    []Cure      # {kind, url} — the ways out, in the order to offer them
  held    id       string      # the approval handle
          clause   string      # the policy clause that stopped the call
          reason   string
  always  request  string      # the response's X-Request-Id, on all three arms

The fourth outcome — the one §6 classifies as an error, where nothing was decided — is a type too, and it is named Fault in all three languages: {status: int, code: string, reason: string, request: string}, raised rather than returned. status is 0 where nothing answered. code carries the RFC 9457 code when the response had one and is empty otherwise, including for every fault an SDK raises before a request goes out.

It is not named Problem, because RFC 9457's problem detail is a body a 402 carries and §6 reads as a denied arm; one word for two things is what §10 exists to prevent. It is not named Error, because that name is taken — by TypeScript's global, by Go's builtin interface, and by Python's own hierarchy — and a type that shadows it reads as the language's rather than as this specification's.

The held arm is parsed from a body whose members are {status: "held", id, clause, reason} — the shape both go-sdk's result.go and python-sdk's pkg/hanzoai/result.py already read. held.id is the handle the approval is known by; no route redeems it today, and §11 records that. An SDK carries the id and offers no method that resolves it, rather than inventing one.

Answer<void> is the arm set with no value. Go spells it Answer[struct{}] and its Value() yields the zero struct; Python's .value yields None; TypeScript's ok arm carries value: void. The arms are otherwise identical.

Cure is {kind: string, url: string}. The SDK field is plural (cures) because it is a list; the wire member is cure, and the SDK MUST NOT rename the wire.

code MUST be carried as a string in all three languages and MUST NOT be an enum, a union of literals, or any closed type. The set is open by construction — it is decided in hanzoai/cloud's errmap.go and middleware_billing.go, and a code added there tomorrow must arrive at a caller as data rather than as a parse failure. The codes present in that repository today are insufficient_balance, spend_cap_exceeded, payment_required, unauthorized and forbidden. A policy refusal has no distinct code of its own yet; §12 records that, and an SDK MUST NOT invent one. Neither policy_denied nor entitlement_required appears anywhere in that repository, so an SDK that branched on either would branch on a string no server sends.

Where a refusal body names no code, code is empty. An SDK MUST NOT derive one from the status: payment_required is a code cloud really answers — it is the error member of every Refusal (§6) — so writing it where the body said nothing would make the spend gate's refusal and an unreadable body the same value.

Each language enforces the same obligation with what it has:

LanguageShapeWhat stops a caller ignoring the arm
GoAnswer[T] with a Status field ("ok"/"denied"/"held"), Value() (T, error), Denied() (*Denied, bool), Held() (*Held, bool)Value returns *Denied or *Held as its error; discarding it yields the zero value and is reported by errcheck
PythonAnswer with a .status attribute, a .value property that raises Denied or Held, and .denied / .held returning the same objects those raisereaching .value on a non-ok arm raises
TypeScripta union discriminated on status: {status:"ok", value:T, request:string} | {status:"denied", code, reason, product?, cures, request} | {status:"held", id, clause, reason, request}a.value does not typecheck until the union is narrowed

request MUST be present on all three arms and MUST carry the response's X-Request-Id header verbatim. It is the join to audit.Event.request (§8.3), and it is what makes the six one client rather than six.

There MUST be exactly one way to call a gated method. An SDK MUST NOT offer a second entry point that returns the bare value, and MUST NOT make the arm opt-in. Go's Read()/Result[T] and Python's result() are removed, not deprecated; TypeScript adds nothing equivalent. No compatibility shim is provided for either.

§6 Classifying a response

One rule, applied identically by every SDK to every gated method. There is no per-accessor variation.

Apply the rows in order and take the first that matches.

#ResponseArmWhy
1any 2xx whose body is a JSON object with status equal to "held"heldthe body decides
2any other 2xxokit ran
3402, any codedeniedmoney refused
4403 whose code is insufficient_balance or spend_cap_exceededdeniedauthenticated, and refused
5everything else — 401, any other 403, other 4xx, all 5xx, transport failureFaultno decision was made, so there is nothing to read

A hold is recognised by the body, never by the status code. Cloud answers a hold with 202, but 202 is also the ordinary answer for "accepted, working on it", so the status code does not separate the two. Testing the body first, at any 2xx, means a hold served at some other 2xx is still read as a hold, and a 202 that is merely an acknowledgement is still read as ok.

Two refusal bodies are served on the routes §8 binds, and both MUST be parsed. The AI surface emits a third, nested shape; that surface is HIP-0040's and no method here reaches it. The first is the RFC 9457 problem detail emitted by hanzoai/cloud's errmap.go, carrying code. The second is that repository's Refusal (middleware_spend.go), which is {error, product, reason, message, cure[]} — its error member is always payment_required and it carries no code. An SDK MUST map the second onto the same denied arm: code from error, reason from message, product and cures verbatim. The wire's own reason member — "unpaid" or "unresolved" — names the admit leg that failed rather than explaining the refusal to a person, and is dropped; message is the sentence a caller shows. §12 records the two bodies as a defect and states what removes it.

One reader covers both, in one place per SDK, by named fallback and not by sniffing for a shape: code is code else error, reason is detail else message, product is verbatim, and cures come from cure and from nowhere else. Nothing else is read — not the wire's reason, not title — so the reader keeps working unchanged when cloud settles on the envelope alone.

Row 4 is a workaround, not a design. It exists because cloud spells "no validated principal" as 403 forbidden rather than 401, so a bare 403 cannot be read as a refusal. When that is fixed (§12) the rule collapses to 402 or 403 ⇒ denied and the code list disappears. While the defect stands an SDK MUST match exactly the two codes named and classify any other 403 as an error. That closed list is a workaround for one server behaviour; it says nothing about the code vocabulary of §5, which is open and MUST stay open.

§7 What is not an Answer

A read that no gate refuses MUST return its value directly. Wrapping it would make every caller branch on an arm that cannot occur, and the arm would stop carrying information.

These return their value: budget.left, budget.balance, budget.plan, budget.spent, audit.list, audit.all, kb.get, kb.list, kb.connectors, kb.connect, kb.links, graph.read, graph.find, graph.resolve, graph.walk, graph.extract, graph.vocabulary.

Every method in §8 is in exactly one of these two sets: the ones listed here and the ones §8 marks gated. An implementer who finds a method in neither has found a defect in this document, not a choice to make.

policy.check returns a Decision carrying a boolean, not an Answer. Asking whether you may act is a question with an answer; being stopped part-way through acting is what produces a denied arm. The two are different events and MUST keep different types.

§8 The six accessors

One accessor per group, one word, the same word in all three languages. Go exports it capitalized (c.Budget) because that is Go's export rule; the word does not change. Method names carry no accessor prefix: c.graph.read, never c.graph.readGraph.

Every method below is listed with its route. A method marked gated returns Answer<T>; the rest return their value per §7.

A type this HIP gives a shape to takes that shape, and the SDK maps the wire onto it. A type named here without a shape — Reindex, Sync, Connector, Link, Triple — takes, field for field, the shape the served document declares for its route. The shapes given below are given because the document declares none, or because the SDK's field names differ from the wire's (§8.3), or because a field carries a constraint a schema cannot state (§8.1's unbounded allowance, §8.4's score).

§8.1 budget

What you may spend, what is left, and what it cost. An allowance and a sum of money are different facts and MUST NOT stand in for each other.

MethodReturnsRoute
left()AllowanceGET /v1/allowance
balance()BalanceGET /v1/billing/balance
plan()PlanGET /v1/entitlement
spent(filter)Page<Charge>GET /v1/billing/usage
  • Allowance is {plan: string, limit: int, used: int, left: int, spent: bool, window: string, resets: instant}. Every count on it is calls; there is no money on the type, and spent is the boolean that says the subject is at the limit. (The served Allowance schema declares spent as a boolean, and all three SDKs read one.) window is "hour" or "day" — whichever will stop the caller next. When limit == 0 the allowance is unbounded: the SDK MUST report left and resets as absent, never as zero, because there is no period to end.
  • Balance is {available: Money, reserved: Money, account: string}. The wire member for reserved is holds (cloud/apps/billing/ledger.go); the SDK renames it because held is an arm of Answer (§5) and one word for two facts is what §10 exists to prevent.
  • Plan is {tier: string, apps: map<string, bool>}. A key is false both when the plan does not grant the app and when the licence authority could not be reached; cloud decides which, and the SDK reports what it is given. An SDK MUST NOT convert a transport failure into false — that would be a read inventing an answer.
  • Charge is {id: string, at: instant, model: string, amount: Money}.

Balance and Charge are modelled by the SDK because the served document declares no response schema for their routes (§12).

POST /v1/usage is deliberately excluded. It ingests a developer's report of what their own provider accounts consumed of their own plans. Binding it here would let a caller believe they were reporting Hanzo spend. No accessor wraps it.

§8.2 policy

May this subject take this action on this object.

MethodReturnsRoute
check(sub, act, obj)DecisionPOST /v1/authz/check

Decision is {allow: bool, sub: string, act: string, obj: string, reason: string}. All three arguments are required. The question is echoed beside the verdict so that a cached or logged decision still says what it answered. reason is empty until cloud fills it (§12); the SDK carries the field as given and MUST NOT synthesize a sentence for a denial it was given no reason for.

The argument order is (sub, act, obj) — subject, verb, object, the order the sentence reads in. The body the served handler binds spells the three members {subject, verb, path}, and the SDK sends that. The {sub, obj, act} spelling appears only in the route's own description, which cloud declares at cloud/plugin/authz/main.go:38; the deployed predicate is hanzoai/authz v1.10.37, pinned at cloud/go.mod:794, whose handler (serve/use.go) binds {subject, verb, path, grants} and answers 400 when subject, verb or path is empty. It answers {allow, subject, verb, path}. §12 records the description as a defect. The two spellings are stated here, once, so no implementer re-derives either from the document.

The predicate is stateless: it decides against grants carried in the request rather than against the org's stored set, and an empty grant set authorizes nothing, so a three-argument check answers false for every question until cloud reads the caller's grants from IAM. Decision is the shape that survives that change.

POST /v1/authz/check declares neither a request body nor responses, so all three generators emit an unusable method. Policy is the one accessor that cannot be built over its generated method: implement it against the shape above, which is the shape the route's own description states.

POST /v1/o11y/authz/check is the same question in a second vocabulary. No SDK binds it (§11).

§8.3 audit

The org's own trail, newest first, read-only by construction.

MethodReturnsRoute
list(filter)Page<Event>GET /v1/audit
all(filter)iterator of EventGET /v1/audit, repeated

Filter is {actor, action, resource, id, result, request, since, until, size, page}, every field optional and every field a narrowing within the caller's own org. Page<T> is {items, total}, where total is the count the filter matched across all pages.

all walks every page by the rule in §9. Go returns iter.Seq2[Event, error], Python an Iterator[Event], TypeScript an AsyncIterable<Event>; in Python and TypeScript a failure part-way through raises out of the iteration rather than ending it, so a caller cannot mistake a broken walk for a finished one.

Six wire fields are renamed for what they carry. Nothing else is renamed for that reason; pageSize and p also reach the caller as size and page, which is a type repair rather than a rename and is recorded in §12.

WireSDKWhy
subactorsub is token vocabulary; the reader wants who did it
timeatone word for an instant, shared with graph.Fact.at
resourceIdidit sits beside resource; the compound says nothing more
requestIdrequestthe same word as Answer.request, which is what makes the join legible
sourceIpipthere is no other address on the row
userAgentagentlikewise

Unchanged: seq, org, email, home, action, resource, method, path, result, status, reason. result is success, deny or error — the wire's own vocabulary, kept verbatim; a row reading deny is the trail's record of a call that returned a denied arm, and the SDK MUST NOT restate it as denied merely to match §5.

There is no write method and MUST NOT be one. The server writes the trail; an SDK that offered a write would let a caller forge its own evidence.

§8.4 search

One ranked result set over everything the org has stored.

MethodReturnsRoute
find(query, opts) — gatedAnswer<Hits>POST /v1/search

Opts is {mode, project, kinds[], index, limit, offset}. mode is one of auto, text, semantic, hybrid, default auto. The modes name kinds of retrieval, not backends. mode constrains which retrieval the server runs, and the server chooses the subsystem that runs it; the SDK exposes no name from Backend.name as an input, so a caller can ask for semantic retrieval but cannot route a query to a named subsystem.

Hits     status    "ok" | "partial" | "unavailable"
         partial   bool          # status != "ok"
         mode      string        # after `auto` resolved
         items     []Hit
         backends  []Backend
         took      Duration

Hit      id, corpus, kind, title, url, project, score, matched []Match
Match    backend, rank, score
Backend  name, status ("ok" | "degraded" | "disabled" | "skipped"), hits, took, error

score is a reciprocal-rank fusion sum and is comparable only within one response — never across queries, and never against a backend's own score, which stays in matched. Every SDK MUST document that on the field itself.

A backend that is down produces the surviving backends' results plus a degraded entry carrying the failure. That is not a denied arm and not an exception: it is an ok arm whose partial is true. An SDK MUST surface partial as a first-class field and MUST NOT report degradation as an empty result set. A caller that cannot see partial reads a truncated corpus as a complete one.

§8.5 kb

The corpus the org writes, and the connectors that fill it. Searching it belongs to search.

MethodReturnsRoute
put(doc) — gatedAnswer<Doc>POST/PUT /v1/framework/kb.{kind}
get(kind, name)DocGET /v1/framework/kb.{kind}/{name}
list(kind, filter)Page<Doc>GET /v1/framework/kb.{kind}
drop(kind, name) — gatedAnswer<void>DELETE /v1/framework/kb.{kind}/{name}
import(format, data, project?) — gated (Python: import_)Answer<Import>POST /v1/knowledge/import
reindex() — gatedAnswer<Reindex>POST /v1/knowledge/reindex
connectors()[]ConnectorGET /v1/knowledge/connectors
connect(provider)LinkGET /v1/knowledge/connectors/{provider}/connect
sync(provider) — gatedAnswer<Sync>POST /v1/knowledge/connectors/{provider}/sync
revoke(provider) — gatedAnswer<void>DELETE /v1/knowledge/connectors/{provider}
links(project?)LinksGET /v1/knowledge/graph
install() — gatedAnswer<void>POST /v1/framework/modules/kb/install

Doc is {kind, name, title, body, project, url} where kind is one of page, memory, source. Only kind is required of a caller; the rest are what that caller has. One method covers three kinds: the SDK maps kind to the doctype kb.<kind>.

put sends PUT /v1/framework/kb.{kind}/{name} when Doc.name is set, and POST /v1/framework/kb.{kind} when it is empty or when the PUT answers 404. The store decides whether the document exists, because the name cannot: kb.page is autonamed field:slug, so a page IS its name, and a page being created carries one exactly as a page being revised does. Reading a present name as "it exists" would leave no way to create a page at all — the PUT would 404 forever and a POST with no name would be refused for the missing required slug. The SDK MUST NOT probe with a GET first: the PUT that 404s wrote nothing, so the POST that follows is the first write, and a concurrent create collides on the unique name rather than being overwritten.

The write body is the document's own field data as its doctype declares it, and nothing else — the framework validates against DocType.Fields and drops anything undeclared before the store sees it. A page keeps its text in body and its name in slug; a memory keeps its text in content; only a source declares a url. name is not a field of any of the three and MUST NOT be sent: the engine reads a body name as a requested name, and all three kb doctypes ignore it (a page is named from slug, a memory and a source are hash- named by the store). A field the caller left empty is left out, so the doctype's own required fields refuse the write and name the one that is missing.

list's filter is {project, order, limit} — the three narrowings GET /v1/framework/{doctype} serves that a person would say (filters, order_by, limit). order is "<field> [asc|desc]". The route publishes no count and takes no page number, so Page.total here is the number of documents on the page; §9's paging rule does not apply to it.

links(project?) narrows to one project scope; absent, it reads the whole org.

import takes format — one of obsidian, notion, roam, evernote — and the export's bytes, sent as the multipart file field. It answers Import{format, imported, pages[]} (cloud/apps/knowledge/import.go). Neither this route nor put's declares a response schema, so both shapes are stated here (§12).

connect answers a URL to send a person to. The SDK MUST NOT follow it — an OAuth consent screen is not a client's to complete.

links() is {nodes[], edges[], partial}: the corpus's own parent, wikilink and provenance edges. It is not graph. It describes documents; graph describes assertions. The two MUST NOT share a type, and an SDK MUST NOT convert between them.

KB writes are not under /v1/knowledge at all — they are generic doctype CRUD at /v1/framework/kb.page, kb.memory and kb.source, which is why the routes straddle two capabilities. The accessor presents one kb and hides the split. An SDK MUST NOT expose /v1/framework/{doctype} generically: a caller who needs arbitrary doctypes is not using this accessor.

install() is required once per org before the first put. It is in the surface only because cloud requires it, and it leaves the surface when cloud stops requiring it (§12).

§8.6 graph

Assertions with provenance and time. Nothing overwrites anything; a retraction is itself an assertion.

MethodReturnsRoute
assert(facts) — gated (Python: assert_)Answer<Wrote>POST /v1/graph
read(filter)[]FactGET /v1/graph
find(query, opts)[]FactGET /v1/graph/search
resolve(entity, relation, at?)ResolutionPOST /v1/graph/resolve
walk(seeds, opts)WalkPOST /v1/graph/neighbors
extract(source)[]TriplePOST /v1/graph/extract
ingest(source) — gatedAnswer<Wrote>POST /v1/graph/ingest
vocabulary()VocabularyGET /v1/graph/vocabulary
  • Fact is {entity, relation, value, names, at, seen, source, evidence, confidence} — what an asserter states — plus {id, by, knowable}, which the server mints and the SDK carries read-only. names: true declares that the value is another entity's key and that the assertion is therefore an edge. id is the assertion's content address, by the identity that filed it, and knowable the first instant this plane could have answered with it, which is what an as-of read is bounded by. GET /v1/graph serves all twelve (cloud/apps/graph/typed.go's wireFact) and no other route hands back a content address, so an SDK that projected the nine would make a read lossy with nothing to recover it from. The write path MUST strip the three: a fact read back and asserted again states the nine and none of the three, so a caller can neither forge a by nor backdate a knowable.
  • Wrote is {recorded, duplicate, refused, reasons[]}. Each member of a batch is judged on its own: one malformed fact MUST NOT discard the batch. Every SDK MUST check the invariant recorded + duplicate + refused == len(facts) on assert, where the caller supplied the facts and can count them; a batch that does not add up is a transport fault and surfaces as an error, never as an Answer. The check does not apply to ingest, which extracts its own facts, so the SDK has no count to compare against. reasons[] explains the refusals but does not index them to particular facts; the SDK carries it as given.
  • read reads by key, {entity, relation, value, at, limit}. It resolves nothing and withholds nothing — a superseded claim and the claim that superseded it both appear.
  • find reads by text. It is the same word as search.find because it is the same act on a different corpus.
  • Resolution is {entity, relation, at, known, winner?, conflicts[], contested, truncated}. known: false is an answer, not an error. contested is true only when a conflict claims a different value than the winner.
  • walk takes Opts{relation, direction, depth, at} and answers {entities[], depth, bound, truncated}. Only edges are followed.
  • extract reads what a document states without recording it; ingest does both, answering the same Wrote. Both take one Source, {source, subject, text, at} — the shape cloud/apps/graph/semantic.go's graphSourceIn binds, where source and text are required and at is required by ingest. It is one type because the two operations take the same thing and differ only in what they do with what they found, and a language that spread it into separate arguments would make the first positional argument of extract a different thing in a different language.
  • Vocabulary is {relations[], rules[], bound} — the relations in use and the ordering that decides a conflict. The wire member is rule; the SDK pluralizes it because it is a list, as with cures (§5).

Instants follow §9. at is when the thing was so; seen is when it became knowable and defaults to at. Cloud refuses an at in the future beyond a small tolerance; the SDK MUST NOT clamp, round, or silently correct one, and a fact refused on its timestamp comes back in Wrote.refused like any other refusal.

Python spells the first method assert_ because assert is a keyword (§10).

§9 Shared types

These types are declared once per SDK and used by every accessor that needs them. An SDK that declares a second Money, a second Page, or a second instant type has already diverged.

Type or fieldWhere it appears
Answer<T>the ten gated methods, which fall in search, kb and graph
Denied.codewhichever gate stopped a gated call — money, policy or entitlement; one string, one branch
Answer.requestall three arms; the join to audit.Event.request
atgraph.Fact.at, audit.Event.at, Charge.at, graph.resolve(at), graph.walk opts
Moneybudget.balance, budget.spent
kindkb.Doc.kind, search.Opts.kinds, search.Hit.kind — one word, page | memory | source
Page<T>audit.list, budget.spent, kb.list
findsearch.find, graph.find

Three rules are stated once here and govern every accessor:

  • Paging. Page<T> is {items, total}, where total is the count the SERVER matched for the filter the SERVER applied. An SDK MUST NOT substitute a count of its own — including where it narrows further on the client, as audit.Filter.request does (§12): there items are the page's matches and total still counts what the server was asked, because a number the SDK made up is a number nobody counted. all() is defined only on audit (§8.3), and it counts what the server sent against that total rather than what survived a client-side narrowing — counting the survivors would end the walk early or not at all. audit.list takes size (default 100) and page in its filter and sends them only when the caller set one; the route's own defaults are the same 100 and 1, so an unset term is a term the route decides identically. budget.spent and kb.list are bounded by their own routes, which publish no count and take no page number, so total there is the page's own length. search and graph.read bound their results with limit/offset rather than Page<T> because their routes do; an SDK MUST NOT convert between the two conventions.
  • Instants and durations. An instant is RFC 3339 on the wire and each language's own instant type in the SDK — time.Time, datetime, Date. This governs graph.Fact.at and seen, audit.Event.at, audit.Filter.since and until, Charge.at and Allowance.resets. A duration is milliseconds on the wire and time.Duration, timedelta, and a number of milliseconds in TypeScript, which has no duration type; this governs Hits.took and Backend.took.
  • Money. Money is {minor: int, currency: string}, where minor counts the currency's minor units as ISO 4217 defines them — hundredths for USD, whole yen for JPY, thousandths for KWD. The field is not named cents, because for two of those three that would be false, and not amount, because Charge.amount is already a Money. Cloud answers USD today. An SDK MUST NOT represent money as a float, anywhere.

Two consequences follow, and each is normative:

  1. A refusal on money and a refusal on policy are read at the same field. The two money codes — insufficient_balance (the wallet is empty) and spend_cap_exceeded (the wallet has money and a cap says no) — are different facts with different cures and MUST NOT be collapsed into one.
  2. held.clause, and whatever names a clause in a policy refusal, MUST use the same vocabulary policy.check reports in Decision.reason for the same question. One clause vocabulary across the pre-check, the hold and the denial; otherwise a caller cannot tell which of its own checks it should have run. Cloud populates neither Decision.reason nor a policy-specific refusal code today (§12), so this binds the SDKs to carry both fields unchanged, not to invent values for them.
  3. Answer.request and audit.Event.request are the same value, so a call and the row recording it can be joined without the caller correlating by hand.
  4. kind is one word in all three places, and it is the bare word — page, memory, source — never the doctype address. The wire carries the address: search's doctypes term matches kb.page, kb.memory and kb.source EXACTLY and silently drops anything else (cloud/apps/knowledge/subsystem.go, sanitizeDocTypes), and search.Hit.doctype comes back in the same spelling. The SDK maps in both directions at the boundary, so a caller narrowing to page narrows the query rather than emptying it. A doctype from another corpus — a lexical row carries its own — has no kb. prefix and passes through unchanged.

§10 The naming rule

An accessor and a method carry the same word in all three languages. Casing follows the language: c.Budget.Left, c.budget.left, c.budget.left.

The only permitted escape is a keyword collision, which takes the language's prescribed form and nothing else. Within this specification that is as_, import_ and assert_ in Python — all three because PEP 8 prescribes the trailing underscore for a name that collides with a keyword. There is no other escape, and an implementer MUST NOT invent one: if a word collides in a language not covered here, that is a question for an amendment, not for the implementer.

§11 Routes no SDK binds

Binding these would give one question two answers in the client.

RouteReason
POST /v1/knowledge/searchthe semantic leg of POST /v1/search with a second request shape and a second hit shape; reached as search.find(q, {mode: "semantic"})
POST /v1/o11y/authz/checkthe policy question in a second vocabulary (relation, object{resource, selector})
POST /v1/graph/graphqluntyped by construction — the caller chooses the output shape; the eight methods of §8.6 are the one way
GET/POST /v1/framework/{doctype}generic doctype CRUD; kb binds three named doctypes and no more
POST /v1/usagea report of a developer's own provider spend, not Hanzo spend (§8.1)

A route absent from the served document MUST NOT be bound at all. Two SDKs currently document GET /v1/approvals/{id} in comments on their hold types (python-sdk pkg/hanzoai/result.py, and the equivalent in go-sdk); no such path is served, and those references are removed rather than implemented.

§12 Known defects, and the workarounds this HIP requires

Each row is a defect in something this HIP does not own. Until it is fixed, every implementer would otherwise write the same workaround three times, so the workaround is specified here once. The fix belongs to the owning capability's HIP, not to this one.

DefectRequired workaroundWhat the fix removes
A missing principal answers 403 forbidden, not 401§6's fourth row: match 403 against a list of refusal codesthe code list; the rule becomes 402 or 403 ⇒ denied
Two 402 bodies are served: the RFC 9457 problem detail with code, and cloud.Refusal with {error, product, reason, message, cure[]}parse both, map both onto denied (§6)one parse instead of two
POST /v1/authz/check declares no request body and no responseshand-write policy.check against §8.2policy becomes generatable
That route's own description states the body as {sub, obj, act} (cloud/plugin/authz/main.go:38), which the deployed handler does not bind — hanzoai/authz v1.10.37 serve/use.go binds {subject, verb, path, grants} and 400s otherwisesend {subject, verb, path}, the body the handler readsthe description and the handler say the same thing, and an implementer can trust either
A policy refusal carries no code of its own, and Decision.reason is unpopulatedclassify a policy refusal by §6 like any other, and carry reason emptya caller learns which clause refused, and §9's one-clause-vocabulary rule becomes checkable
GET /v1/billing/balance and GET /v1/billing/usage declare no response schemahand-model Balance and Charge per §8.1budget stops being modelled in three places
POST /v1/knowledge/import and POST /v1/framework/{doctype} declare no response schemahand-model Import and Doc per §8.5kb stops being modelled in three places
GET /v1/audit accepts no requestId filter, though every row carries oneship Filter.request, fall back to {action, since} and match request client-sidethe audit join stops being a client-side scan
GET /v1/audit declares pageSize and p as stringsserialize integers as strings at the boundarynothing else; the SDK types stay integers either way
GET /v1/framework/{doctype} publishes no count and takes no page number, so a listing cannot be paged or sizedreport Page.total as the page's own length (§8.5) and bound with limitkb.list pages like every other listing
kb.page is autonamed field:slug, so a PUT to a page that does not exist yet answers 404 and a POST with no slug is refusedput sends the PUT and follows a 404 with the POST (§8.5)the caller's name decides the method, in one round trip
POST /v1/framework/modules/kb/install must be called before the first writeexpose kb.install() (§8.5)kb.install leaves the surface
POST /v1/graph/ingest validates the body before resolving tenancy — an unauthenticated POST {} answers 400, not 403none; the SDK always authenticatesan unauthenticated caller stops learning the validation rules

Every row above is an observation, not a requirement on cloud. The schema absences were read from the served document captured on 2026-09-08; the status codes and the two 402 bodies were read from cloud/errmap.go, cloud/middleware_spend.go and cloud/middleware_billing.go on 2026-09-10, when api.hanzo.ai was answering 530 at the edge and could not be probed. An implementer who finds a row no longer true should delete the workaround, not preserve it.

Per-call cost is not attributable today: no metered response carries the debit, so a caller wanting to know what a call cost must read budget.spent afterwards. Answer therefore carries no cost field. A field that is absent is honest; a field that is present and always empty is not.

§13 Conformance

An SDK conforms when all of the following hold. Every item is observable from outside the package.

  1. One client type carrying six accessors named budget, policy, audit, search, kb, graph, constructed per §2 with the five options and their environment fallbacks.
  2. Credentials obtained per §3, and no code path that accepts a bearer token or reads HANZO_API_KEY.
  3. As/as_/as per §4, and no method anywhere in §8 taking an org or subject.
  4. Answer<T> per §5, returned by every method §8 marks gated, with no second entry point and no opt-in, and Fault per §5 raised for every outcome §6 classifies as an error — one name for the fourth outcome, in all three.
  5. Response classification per §6, identical across the three.
  6. The methods, types, field names and renames of §8, spelled exactly as written.
  7. Shared types declared once per §9.
  8. None of the routes in §11 bound.
  9. NewClient(apiKey), HANZO_API_KEY, Configuration(access_token=), Read() and result() removed. Not deprecated, not aliased, not kept behind an option.
  10. The hand-written layer sits over the generated client, in the same package, reading the same served document as the generated code. It is never a parallel client and never a second package.

Status per language, as measured on the working trees at 2026-09-08:

LanguageStateWhat exists that is adjacent
Gonone of this implementedAs(subject) exists, but mints at POST {issuer}/v1/iam/tokens/issue?id=, the bespoke verb HIP-0111 §7 forbids, not §4's exchange; Result[T] exists but is opt-in, where §5 requires the arm always
Pythonnone of this implementedClient and as_(subject), using the same forbidden verb; Held raised by default, which §5 replaces
TypeScriptnone of this implementedno hand-written layer of any kind; every file under src/ is generated

The three .spec-lock files pin the same document digest (sha256=2dd32162f86963c080b1139549d593139ba6a3cb30533ef5abaf04881e7ef183), so all three generate from identical bytes; the ref recorded beside it differs in go-sdk, which is a bookkeeping difference and not a content one. Conformance requires the digests stay equal.

Rust is out of scope. HIP-0040 covers four languages; this HIP covers the three whose repositories are public, because §1.3's rule applies to specifications as well as to dependencies — a conformance claim a reader cannot check is not a claim. Rust adopts this surface by amendment when its repository is public.

Rationale

Why an answer type rather than exceptions. The obvious alternative is to let HIP-0040's error classification handle refusals: a 402 becomes a PaymentRequiredError, a 403 a ForbiddenError, and the caller catches them. That is one line shorter at the call site and wrong in two ways. A refusal carries a cure — an ordered list of ways out — and an exception path is where callers put the code they wrote once and never look at again. And a refusal is a normal outcome for a metered call: making the normal outcome an exception inverts which path gets the attention. The cost of Answer is that ten methods return a type the rest of the SDK does not use. That cost is paid once per SDK, in a layer of a few hundred lines, and it is the whole reason a caller can act on a denial instead of logging it.

Why one word across three languages. The alternative is to let each language name things idiomatically — GetAllowance in Go, get_allowance in Python, allowance() in TypeScript. Idiomatic naming is a real virtue and it is not worth what it costs here: a team running Python for training, TypeScript for the front end and Go for the backend then reads three vocabularies for one product, and every question about behaviour has to be asked three times. Casing is the language's; the word is the contract's.

Why a hand-written layer at all, when HIP-0040 exists to avoid one. HIP-0040's argument against hand-written SDKs is an argument against hand-writing the transport, and it holds. This layer writes no transport: it calls the generated methods and shapes what comes back. It exists because five of the routes it binds declare too little for a generator to work with (§12), and because the grouping in §1.2 spans capabilities and therefore spans generated classes, which no generator can invent. Both of those are conditions of the served document, and when they change the layer shrinks. It does not become unnecessary: Answer is a client-side decision about how a refusal is presented, and no document could generate it.

Why the six, and not five or twelve. Each of the six is a question a program asks about its own execution rather than about a user's data — what may I spend, what may I do, what did I do, what is known, what did we write down, what do we hold as true. That is what makes them one client. A route that answers a question about someone else's data belongs to its capability's generated class and is reached there.

Security Considerations

What an out-of-band bearer costs. §3 is the only place a token enters an SDK, and it enters as the result of an exchange the SDK performed with credentials it holds. Accepting a bearer token from the environment — which all three SDKs do today — moves the decision about who is calling out of the SDK and into whatever process set the variable. That token is rotated by nobody, scoped to nothing in particular, and indistinguishable at the server from any other holder of the same string. The RFC 8707 resource parameter is what keeps a token minted for api.hanzo.ai from being replayed against another audience, so it is not optional and has no default other than the base URL.

Why a scoped client drops the operator credential. A scoped client that kept the operator credential alongside the subject's would present two answers to who is calling, and which one a server honours becomes a property of that server's header precedence rather than of the client's intent. The operator credential leaves with the scope. This is also why no method takes an org: a tenant argument is a caller asserting its own tenancy, which is the shape of every cross-tenant read.

A denial must not become an exception, and an exception must not become a denial. §6's last row is the security-relevant one. A 401, a 5xx and a transport failure mean no decision was made. An SDK that reported those as denied would let a caller treat a network partition as a policy answer and cache it, or show a user a cure for a problem they do not have. The converse matters as much: an SDK that raised on 402 would push the refusal into a handler that never reads cures.

Audit is read-only by construction, not by convention. §8.3 defines no write method. If one existed, a caller could write rows to the trail that records its own behaviour. The trail's value rests entirely on its author being the server.

Why partial is a field rather than an inference. §8.4 requires it as a field a caller can see. A search that silently returns the surviving backends' results gives a caller a truncated corpus with the shape of a complete one. Whatever consumes the result then treats a subsystem outage as a finding that nothing matched.

Why enumerating the routes is safe. The routes this HIP binds are enumerated in a document HIP-1030 requires to be readable without a credential. That is safe for the same reason it is safe there: every route named remains individually authorized, and no route in §8 may rely on a client not knowing its address.

References

  • HIP-0040 — Multi-Language SDK Standard, the generated layer this one sits over
  • HIP-0111 — Hanzo IAM Authentication Standard, the credential of §3
  • HIP-0139 — Capability, the object §1.2 distinguishes an accessor from
  • HIP-0526 — Semantic Memory, the substrate under the graph
  • HIP-1030 — OpenAPI, the served contract §11 defers to
  • HIP-1041 — Authz, the policy question of §8.2
  • HIP-1101 — Allowance, the free lane's ceiling read by budget.left
  • HIP-1103 — Audit, the tamper-evident trail read by audit.list
  • HIP-1126 — Framework, the DocType engine kb writes through
  • HIP-1147 — Search, hybrid retrieval
  • HIP-1198 — Graph, the assertion plane at /v1/graph
  • HIP-1202 — Entitlement, what an org may run, read by budget.plan
  • HIP-1220 — Commerce, the merchant half that answers the money reads
  • HIP-1260 — Knowledge, documents and their vector index
  • github.com/hanzoai/semantic — the reference implementation of HIP-0526
  • RFC 2119 — Key words for use in RFCs
  • RFC 3339 — Date and Time on the Internet: Timestamps
  • RFC 6749 §4.4 — The OAuth 2.0 client credentials grant
  • RFC 8707 — Resource Indicators for OAuth 2.0
  • RFC 9457 — Problem Details for HTTP APIs

Copyright

Released under CC0 1.0 Universal Public Domain Dedication.