Sync
Package sync is data sync: link two endpoints and keep them in step, on a webhook, on a schedule, or on demand.
Package sync is data sync: link two endpoints and keep them in step, on a webhook, on a schedule, or on demand.
| Base URL | https://api.hanzo.ai |
| Operations | 6 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1154 · Sync — Two Endpoints Kept in Step — Draft · read the specification →
/v1/sync is data sync: a Sync names two endpoints and the engine keeps them in
step, on a webhook, on a schedule, or on demand. Git — GitHub/GitLab in either
direction with native Hanzo Git — is the one provider registered today; another
kind is another Provider, with nothing in the engine to change. It is implemented
in hanzoai/cloud at apps/sync, and its safety property is a single rule: the
forge is canonical and an inbound sync may only fast-forward it.
Motivation
Customers arrive with their code on a host they are not leaving on day one, and the forge is only useful if it holds the same history without anyone pushing twice. Before this capability the pieces were scattered: triggers synced directly, the git hardening lived beside a retired object store, and no record existed of what an advance had done. Routing every trigger through one engine makes "why did this ref move" a question with one answer in one store.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
The store
Per-org SQLite files through the org-store registry: storeFor is the one way
the package reaches a store, naming the database through the single door a
validated org walks through (apps/sync/sync.go, storeFor). The store holds the
sync intent, the engine's cursor, and the two facts the forge cannot hold — what
an advance did, and where a repo replicates to. A sync is org-scoped, not
project-scoped: a link binds two endpoints within one org.
The address
Six operations under /v1/sync: list, create, get, patch, delete, and
POST /v1/sync/{id}/run for a manual reconcile. All are typed except the
delete, which answers 204 with no body — there is no value to type. Triggers
(the GitHub App webhook, the forge push webhook) resolve to Syncs and call the
registered reconcile — they never sync directly, so the engine is the single
seam. The same reconcile and the git object seams are offered on the internal
plane to the processes the triggers actually land in
(apps/sync/run_plane.go, apps/sync/import_plane.go).
The engine
One place a sync happens: resolve → loop-guard → cursor dedupe →
provider.Apply → chain, hop-bounded, kind-agnostic (apps/sync/engine.go). A
trigger is webhook, poll or manual; poll freshness is a periodic reconcile
sweep that stays dormant until CLOUD_SYNC_RECONCILE_INTERVAL is set on the
deployment (apps/sync/scheduler.go).
Fast-forward only
An advance moves one ref from one git host to another and MUST refuse to move it
any way but forward: if the upstream's tip is not a descendant of what the forge
holds, the two have diverged and the answer is a CONFLICT with the forge
untouched — never an overwrite (apps/sync/advance.go). Getting this wrong is
silent — a force-push that eats a colleague's commits leaves a repository that
looks fine — so the rule is enforced by the transfer itself, a non-forcing
refspec, not by a check beside it.
Tenancy
The org is principal.Acting from the validated principal
(apps/sync/sync_api.go:110); storeFor accepts only an already-validated org,
so which file a request touches has one answer from one input.
Money, events, observability, stage
It is free — the surface declares cloud.Free (plugin/sync/main.go). It
publishes nothing on the bus: webhooks are its inputs, not its outputs. It emits
nothing beyond the request span every route gets; the advance record in the
store is the durable account of what a reconcile did. The stage is ga: it is
developer-tools core, the mechanism that keeps the forge in step with the hosts
customers already use.
Upstream
The one external program is the git client (GPL-2.0), resolved once via
LookPath and run as a subprocess — never linked (apps/sync/gitexec.go).
GitHub and GitLab are remote protocols spoken, not code embedded. Everything
else is the fleet's own.
Rationale
The alternative to one kind-agnostic engine is a git-sync feature, and then a second engine when the second kind arrives. The Provider seam costs one indirection and buys the loop-guard, cursor dedupe and hop bound being written once. The alternative to fast-forward-only is conflict resolution, which for a source of truth is a euphemism for choosing whose commits to destroy; refusing with the forge untouched leaves the decision to a person holding both histories.
Security Considerations
The dangerous shape is fixed: the upstream URL is tenant-influenced, the
credential is ours, and the process runs in our pod. Every rule the hardened
subprocess seam enforces exists for one attack — an arg slice, never a shell
string, so a URL is never interpreted; a minimal environment inheriting none of
the server's secrets; the credential carried only by env-injected
http.extraHeader, never argv (world-readable in /proc) and never the URL
(written to logs and reflogs); http/https only with redirects refused, so a
source cannot smuggle a file:///ext:: transport or bounce a fetch onto an
internal address; an SSRF guard on every tenant-supplied host; pack subprocesses
bounded in count and memory; stderr capped and redacted (apps/sync/gitexec.go).
The wrong implementation of any one of these hands a tenant our git credential or
a request forger inside the cluster network.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | sync at its own prefix | 6 operations |
| CLI | hanzo sync … | 6 of 6 |
| SDK | SyncApi in every published client | 6 methods |
| MCP | tool sync on https://api.hanzo.ai/v1/mcp | 6 operations, 2 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/sync, operation get_sync:
hanzo sync listimport { Configuration, SyncApi } from 'hanzoai';
const api = new SyncApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getSync();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import SyncApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = SyncApi(client).get_sync()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.SyncAPI.GetSync(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, sync_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = sync_api::get_sync(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.SyncApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new SyncApi(client).getSync();curl https://api.hanzo.ai/v1/sync \
-H "Authorization: Bearer $HANZO_API_KEY"Tool sync, op get_sync — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "sync",
"arguments": {
"op": "get_sync",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/sync/{id}/run | Run reconciles one sync now — the manual re-sync, and the initial import for a link created without run=true. |
GET /v1/sync/{id} | Get returns one sync by id. |
PATCH /v1/sync/{id} | Patch updates one sync's mutable policy — direction, trigger and actor — in place. |
DELETE /v1/sync/{id} | Delete removes one sync and tears down the outbound mirror it derived, answering 204. |
GET /v1/sync | List returns every sync link the caller's org has, each with its two endpoints, its direction and trigger policy, and the time it last reconciled. |
POST /v1/sync | Create declares a sync between two endpoints and returns it. |
How is this guide?