Provisioning
Package provisioning is one-click data add-ons: a SQL, key-value, document, vector, search or object store, wired straight into your app.
Package provisioning is one-click data add-ons: a SQL, key-value, document, vector, search or object store, wired straight into your app.
| Base URL | https://api.hanzo.ai |
| Operations | 28 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1164 · Provisioning — Stores on Demand — Draft · read the specification →
/v1/instances is one-click data add-ons: a SQL, key-value, document, vector,
search or object store, wired straight into your app. It is a control plane and
only that — it allocates the resource, records one row about it, and hands back
the one credential that reaches it. It never reads or writes a byte of what the
resource then holds. It is implemented in hanzoai/cloud at apps/provisioning
(HIP-0106).
Motivation
Seven engines, each asked for the same four ways. Without one address for the asking, each grows its own allocation surface, its own name-to-tenant derivation and its own idea of what a credential is scoped to — and the seventh copy of "derive a physical name from an org" is the one that folds two tenants onto one resource. The allocation is the part that must be identical across engines; what you do with the resource afterwards is the part that cannot be.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store
One store: an encrypted SQLite named provisioning, opened through the one
opener so it is born encrypted, single-connection so every write is atomic
against the file lock (apps/provisioning/store.go:73). One table,
provisioned_resources — one row per logical resource, carrying the org, the
kind, the friendly name, the derived physical name, the endpoint, the declared
size, the app instance it is bound to, and secret_ref. It NEVER carries a
password. Two unique indexes do the work: (org, kind, name), and
physical_name globally across orgs — the second is the authoritative guard
that two logical resources can never map onto one physical resource
(store.go:105-114).
§2 The address, and the seven kinds
/v1/instances/{kind} for seven kinds — kv, sql, docdb, datastore,
s3, search, vector — each answering the same four operations: list,
create, describe, drop. The kind is a path segment because it is a value from a
closed set, not a capability: /v1/kv, /v1/sql, /v1/docdb and /v1/datastore
carry no operation, and an engine MUST NOT take a top-level prefix for its
allocation surface. Allocation is one act with one store, so it has one address.
The reads and the drops are typed ops, spelled one constant route per kind
rather than registered from a loop: a computed path has no identity for
zipdoc to file prose under, so a looped registration can carry no doc comment
and reaches none of the projections (apps/provisioning/typed.go).
The seven creates are declared and untyped, and the reason is a wire fact.
A create runs the pre-provision balance gate, whose refusal cloud.DenyResource
renders as the money wire's nested {"error":{"code","message"}} at 402 or 503.
A typed op's only refusal channel is a returned error, which zip renders as its
flat HTTPError shape — the same denial in a different body for every metered
client that reads error.code across the fleet. Writing the nested body from
inside the op does not escape it either: a nil Out makes zip stamp its own
status over the 402. Moving the gate into middleware does not rescue it, because
middleware runs before the body decode and would turn a malformed name from 400
into 402. So the creates keep their closure and DECLARE their request and
response bodies through openapi.Register instead — an undeclared create
publishes a method with nowhere to put the name, which is strictly worse than an
under-described one. apps/provisioning/typed_wire_test.go holds that as a
closed list, so an eighth untyped route here goes red.
The pair /v1/instances provisioning is carried by cloud's
openapi/misfiled.txt and closes by fold (HIP-0139 §7.1), never by alias: this
capability has one store and one act, however many kinds it names, and §7.1 is
the default for exactly that shape. It does NOT close by rename: instance
names the resource, and a capability is the faculty (HIP-0139 §2.2).
§3 Tenancy
A validated principal is required first, and the refusal is the point: without
it, the org is forgeable by anything that can reach the port, and what a forged
org buys here is not a read — it is the victim's connection string and password,
the ability to destroy the victim's store, and an enumeration of what they hold
(apps/provisioning/provisioning.go:531). With the principal established, the
tenant is the org the edge minted from the validated bearer owner claim
(HIP-0026), folded to a DNS slug. An empty org is refused unless the caller is a
SuperAdmin, and that fallback reaches the literal admin org's own physical
namespace and no real tenant's.
Isolation is then by construction, two ways, one per strategy:
- Dedicated instance —
kv,sql,docdb,datastore. The org gets its OWN instance: a Datastore CR (HIP-0401) in the org's owntenant-<org>namespace, which the operator reconciles. Its admin credential is naturally tenant-scoped because the org owns the whole instance. The assembled DSN is injected as<KIND>_URLinto the addons Secret of the app instance named in the create, so that instance switches onto it. - Shared logical —
s3,search,vector. The resource is a logical one inside an engine that is already running, namedo<orgHash>_<name>where the org hash is FIXED WIDTH. The fixed width is the whole guard: it makes the org-to-name boundary unambiguous, so(org, name)is injective up to a 64-bit collision and no two tenants fold onto one resource. The globalUNIQUE(physical_name)index makes any residual fold fail closed with 409.
The friendly name is validated at the door against
^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$; every physical name and every engine
identifier derives from it, so that regex is the injection guard.
A kind whose engine cannot grant a per-tenant-safe credential MUST be refused
with an honest 503 rather than granted a cross-tenant one (unavailableKinds,
empty today: the four kinds a shared engine cannot scope a per-tenant role on
take the dedicated strategy instead). A kind is never both refused and
dedicated.
§4 Money
Metered, and the unit is one create. The fee is
cloud.ResourceFeeCents("CLOUD_PROVISION_FEE_CENTS", kind): a per-kind operator
knob over a global one over cloud.DefaultResourceFeeCents, a dollar. The gate
runs BEFORE anything is created — an unfunded org gets 402, an unreachable
ledger 503 in the fail-closed posture, and nothing is provisioned either way.
The debit lands after success through the one shared cloud.ResourceMeter,
labelled with the kind. A fee of 0 makes a kind free and therefore un-gated.
Reads and drops are free.
A dedicated instance also carries a recurring footprint charge: one GB-day tick
per dedicatedMeterInterval, priced from the row's declared size at
CLOUD_STORAGE_PRICE_CENTS_PER_GB_MONTH (defaulting to the value in
hanzoai/pricing), rounded up so a footprint is never undercharged and floored
at one cent so a running instance is always billed. It is the SAME meter — there
is no second metering path — and it runs only where billing actually enforces.
§5 Events
It publishes nothing on the bus; a customer's webhooks receive no
provisioning.* events.
§6 Observability
Beyond the request span every route gets, structured log lines only: the mount line naming the configured strategies and whether billing enforces, and the degrade warning when secret custody is unavailable. It emits no metric of its own. The seal of a generated credential is recorded by the custody plane (HIP-1134), not here.
§7 Stage
ga. The manifest row declares no stage, and absent is ga (HIP-0139 §8).
§8 Upstream
It derives from no upstream code: it forks nothing, embeds no engine and mirrors
no project. It persists through github.com/hanzoai/sqlite and
github.com/hanzoai/cek, seals through the custody client, and reaches each
engine over that engine's own protocol — an index create, a collection put, a
bucket make, or a CR the operator reconciles. The engines themselves are
specified where they are deployed; nothing of them is linked into this app.
§9 The boundary: allocate, then operate
This capability allocates and destroys. It never reads or writes a row, a document, an object or a vector. Where the resource is then reached splits three ways, and each side is somebody else's:
s3— the customer data plane iss3at/v1/s3(HIP-1165). Both derive the physical bucket from the caller's org through the SAME exported derivation, so a bucket allocated here is browsable there. They MUST derive it identically or the tenant boundary drifts between allocate and operate, which is worse than either side being wrong alone.search,vector— the allocated index or collection is reached at the endpoint the create returned. The operator's view of the shared vector store is this capability's own, atGET /v1/admin/provisioning/vector/collectionsand/stats— the collections with their size and geometry, and the totals — because this is what allocates into that backend, and the gate there isprincipal.IsSuperAdmin, the predicate every other/v1/adminroute asks, rather than a vector master key of its own. The lexical store's inventory issearch's, beside the ranked answer it serves (HIP-1147).kv,sql,docdb,datastore— reached only over the engine's own wire protocol, at the host, port and credential the create returned. There is no HTTP data plane for them, and there MUST NOT be one under this capability: a proxy here would be a second door onto a store whose first door already authenticates, with a credential this app would then have to hold.
The credential is returned exactly once, in the create response, and nowhere else. Every read beside it carries no password. A caller that does not keep it re-provisions.
Rationale
The alternative is an allocation surface per engine. Each would need the same tenant derivation, the same balance gate and the same credential lifecycle, and the seventh copy is where they stop agreeing — which is why the one property that must hold across all seven, that a name maps to exactly one tenant, lives in one function and one unique index rather than in seven implementations of an intention.
The two strategies exist because a credential's scope is a property of the engine, not of this control plane. An engine that cannot mint a per-tenant credential can only be given a cross-tenant one, so the four kinds a shared engine cannot scope get their own instance rather than a shared grant. The mechanism that refuses an unscopable kind stays even though nothing is refused today, because the next kind is the one that needs it.
Security Considerations
A wrong implementation gives an attacker three things, each closed here structurally.
Another tenant's store. A folded name is a credential takeover, not a leak:
two orgs resolving to one physical resource share its password. The fixed-width
org hash makes the fold unspellable and the global UNIQUE(physical_name) makes
any residual one fail 409 rather than succeed quietly.
A store in someone else's name. Without the validated-principal check, a caller inside the network could name an org and receive its connection string, drop its data, or enumerate it. The check refuses exactly the anonymous path and nothing else: every real caller arrives with a validated principal.
A password at rest. When custody is unavailable the create returns the
generated password once and persists only metadata, secret_ref empty. It
never writes a plaintext password anywhere durable, and a degraded custody plane
MUST NOT be resolved by writing one.
The tenant boundary is the naming derivation plus the validated principal, and both are server-side with no caller input. The name regex is the injection guard for every identifier downstream of it — a SQL identifier, a namespace, a Secret name — so widening it is not a usability change.
The dedicated strategy places an org's instance in the org's own namespace, which makes the k8s boundary carry the isolation rather than a credential scope; the shared strategy has no such layer, so the physical naming and the unique index are the whole boundary there and are written where no handler can skip them.
The one-time credential return is a deliberate trade: it removes a durable plaintext copy at the cost of a caller that must keep what it asked for.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | provisioning at its own prefix | 28 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 | tool provisioning on https://api.hanzo.ai/v1/mcp | 28 operations, 0 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/provisioning/kv, operation get_provisioning_kv:
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, ProvisioningApi } from 'hanzoai';
const api = new ProvisioningApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getProvisioningKv();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import ProvisioningApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = ProvisioningApi(client).get_provisioning_kv()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.ProvisioningAPI.GetProvisioningKv(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, provisioning_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = provisioning_api::get_provisioning_kv(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.ProvisioningApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new ProvisioningApi(client).getProvisioningKv();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/provisioning/kv \
-H "Authorization: Bearer $HANZO_API_KEY"The door reaches provisioning through the provisioning tool, which names its 28 operations with its own verbs — this one among them, under a name only the door declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "get_search"
}
}
}'Answers 200 with provisionedSummary[] — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/provisioning/datastore/{name} | Returns one Hanzo Datastore warehouse's metadata. |
DELETE /v1/provisioning/datastore/{name} | Deprovisions one Hanzo Datastore warehouse. |
GET /v1/provisioning/datastore | Lists the caller org's Hanzo Datastore warehouses. |
POST /v1/provisioning/datastore | Launches your org's OWN Hanzo Datastore instance and answers with its datastore:// connection string. |
GET /v1/provisioning/docdb/{name} | GetDocDB returns one Hanzo DocDB database's metadata. |
DELETE /v1/provisioning/docdb/{name} | DropDocDB deprovisions one Hanzo DocDB database. |
GET /v1/provisioning/docdb | ListDocDB lists the caller org's Hanzo DocDB document databases. |
POST /v1/provisioning/docdb | CreateDocDB launches your org's OWN document-database instance and answers with its mongodb:// connection string. |
GET /v1/provisioning/kv/{name} | GetKV returns one Hanzo KV store's metadata. |
DELETE /v1/provisioning/kv/{name} | DropKV deprovisions one Hanzo KV store. |
GET /v1/provisioning/kv | ListKV lists the caller org's Hanzo KV stores. |
POST /v1/provisioning/kv | CreateKV launches your org's OWN key-value instance and answers with its kv:// connection string. |
GET /v1/provisioning/s3/{name} | Returns one bucket's metadata. |
DELETE /v1/provisioning/s3/{name} | Deletes one bucket from the shared object store and removes its metadata row. |
GET /v1/provisioning/s3 | Lists the caller org's object-storage buckets. |
POST /v1/provisioning/s3 | Creates an S3-compatible bucket inside the already-running shared object store and answers with the endpoint that reaches it. |
GET /v1/provisioning/search/{name} | Returns one search index's metadata. |
DELETE /v1/provisioning/search/{name} | Deletes one search index from the shared backend and removes its metadata row. |
GET /v1/provisioning/search | Lists the caller org's search indexes. |
POST /v1/provisioning/search | Creates a search index inside the already-running shared search backend and answers with the endpoint that reaches it. |
GET /v1/provisioning/sql/{name} | GetSQL returns one Hanzo SQL database's metadata. |
DELETE /v1/provisioning/sql/{name} | DropSQL deprovisions one Hanzo SQL database. |
GET /v1/provisioning/sql | ListSQL lists the caller org's Hanzo SQL databases. |
POST /v1/provisioning/sql | CreateSQL launches your org's OWN PostgreSQL instance and answers with its postgres:// connection string. |
GET /v1/provisioning/vector/{name} | Returns one vector collection's metadata. |
DELETE /v1/provisioning/vector/{name} | Deletes one vector collection from the shared backend and removes its metadata row. |
GET /v1/provisioning/vector | Lists the caller org's vector collections. |
POST /v1/provisioning/vector | Creates a vector collection inside the already-running shared vector backend and answers with the endpoint that reaches it. |
How is this guide?