Compare commits

...
Author SHA1 Message Date
hanzo-dev b59f77a36d refactor(cloud): install the inference module's hooks from the composition root
Package cloud imported github.com/hanzoai/ai/object to hand the co-resident
inference module four callbacks over plain data: a plan-tier read, a prepaid
balance read, a usage debit, and a durable-queue dialer. cloud is imported by
nearly every clients/* package, so that one edge linked the module's entire
closure into all of them.

Measured, go list -deps, origin/main f25b2c4c vs this branch:

  package cloud            2151 -> 671   (-1480)
  79 packages shrink, 0 grow, 114304 package-edges removed
  clients/crm  (modal)     2152 -> 672
  clients/deploy (least)   2458 -> 1413

Gone from those closures: k8s client-go (267) + api/apimachinery (115) +
kustomize (74), aws-sdk-go-v2 (73), grpc (65), go-git (47), volcengine (31),
azure-sdk-for-go (30), docker (22), luxfi/geth (21), openai-go (20),
anthropic-sdk-go (19), pdfcpu (19), gooxml (18). Link of the root test binary:
2.18 GiB peak RSS -> 776 MiB, 164 MB -> 59 MB.

The callbacks now travel the other way. cloud exposes RegisterBillingInstaller
and calls the registered installer at the end of BuildDeps, where the metering
client and the published ledger both exist; apps/ai.go — the composition root,
the ONE package that imports both sides — installs them, alongside the ingest
dialer, which resolves the embedded tasks engine lazily through the already
exported cloud.EmbeddedTasks(). Same inversion as telemetryInstaller and
kmsClientFactory, so there is one way to do this, not a second one.

Not a MountSpec: MountAll skips any spec whose name is absent from an explicit
CLOUD_ENABLE list, and a money gate that silently stops being installed is a
revenue leak rather than a degraded feature.

Behavior is unchanged by construction. Every hook keeps its co-residency
condition (metering enabled / ledger published), so an uninstalled hook still
means "take the module's own HTTP path", and the ingest dialer returns the
module's own ErrTasksNotConfigured when no engine is up, which is the sentinel
its handler matches to run the ingest inline.

Proof, not assertion: apps.TestWireInstallsAIHostBindings walks the production
path (Wire then BuildDeps) and asserts all three money hooks are installed, then
credits a subject wallet and reads and debits it THROUGH the installed hooks —
500 cents in, two 1.005 USD debits, 299 out — which pins both the same-wallet
invariant and the sub-cent exactness. cloud.TestBuildDepsInvokesBillingInstaller
pins the other half: invoked exactly once, with metering built and the ledger
already published, asserted from inside the callback.

The CI containment job gains the matching graph assertion, since a re-added
aiobject.SetX in build.go compiles and passes every test.

Binaries are untouched: cmd/cloud's dep set is bit-identical (4460), because
apps legitimately links the module for zen. clients/{guide,usage,analytics,
samples} are unchanged too — they import ai/object directly.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:16:31 -07:00
11 changed files with 419 additions and 108 deletions
+21
View File
@@ -197,3 +197,24 @@ jobs:
echo "OK: containment holds — clients/controlplane has zero buildable files by default and is linked into no cmd/ binary"
- name: positive proof — package cloud does not link the inference module
# Package cloud is imported by nearly every clients/* package, so one import
# edge out of it is an edge out of all of them: importing
# github.com/hanzoai/ai/object here pulls that module's whole closure —
# k8s client-go, the AWS/Azure/Volcengine SDKs, go-git, docker, ~1.5k
# packages — into every one, to hand the module four callbacks over plain
# data. The callbacks now go the other way: cloud exposes
# RegisterBillingInstaller and the composition root (apps/ai.go), which is
# linked only by cmd/, installs them.
#
# A re-added `aiobject.SetX(...)` in build.go compiles and passes every test
# — the cost is invisible until someone measures a link again. Same graph
# assertion as the containment step above, for the same reason.
run: |
set -euo pipefail
if go list -deps github.com/hanzoai/cloud | grep -qx 'github.com/hanzoai/ai/object'; then
echo "::error::package cloud reaches github.com/hanzoai/ai/object — that edge re-links ~1480 packages into every package that imports cloud. Install the module's hooks from apps (cloud.RegisterBillingInstaller), not from the root."
exit 1
fi
echo "OK: package cloud does not reach github.com/hanzoai/ai/object"
+134
View File
@@ -0,0 +1,134 @@
// Copyright © 2026 Hanzo AI. MIT License.
package apps
import (
"context"
"fmt"
aiobject "github.com/hanzoai/ai/object"
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/types"
)
// ai.go binds what the co-resident inference module (hanzoai/ai) resolves from its
// HOST process: the money plane its prepaid gate reads and debits, and the durable
// queue it hands long ingests to. The module declares each as a typed hook over
// plain data and calls whatever the host installed — so a debit is a direct Go call,
// not an HTTP request the host has to re-route back to itself.
//
// The composition root is the ONE place that may know both sides. Package cloud must
// not: it is imported by nearly every clients/* package, so calling the module's
// setters from there linked the module's whole closure (~1.5k packages) into all of
// them to hand over four functions. Same decoupling as wire_seams.go and ledger.go —
// the root composes leaves that must not import each other.
// installAI installs every host binding the inference module resolves. Called by
// Wire(), before cloud.Serve, so both bindings are in place before the module mounts
// and long before it can serve a request.
func installAI() {
// The durable queue. dialTasks resolves the engine at DIAL time, not here: the
// engine starts after MountAll, long after Wire().
aiobject.SetIngestDialer(dialTasks)
// The money plane needs the metering client and the published ledger, so cloud
// calls back once BuildDeps has built both.
cloud.RegisterBillingInstaller(installBilling)
}
// installBilling binds the module's prepaid gate to cloud's in-process money plane.
// Three hooks, each fail-SAFE or fail-CLOSED exactly as the money it guards demands:
//
// - TIER (subscription): the caller's commerce plan, read through the SAME
// co-resident commerce client the metering gate bills over — in-process when
// commerce is folded in, S2S HTTP with the service token otherwise, NEVER an
// authed self-call to the cloud edge. That self-call is the toothless-gate bug:
// the edge 401/403s a service call to /v1/billing/*, so the module's own HTTP
// lookup always returned "" in-cluster and every tier-gated SKU failed OPEN.
// Client.Tier folds a commerce error or an unknown plan to "", which the gate
// treats as ALLOW, so a commerce blip never locks out a paying caller.
//
// - BALANCE + USAGE (cash): the per-org SQLite double-entry wallet. There is NO
// exempt path (hanzoai/ai >= v1.805.8): every principal is gated on a positive
// prepaid balance, fail-closed.
//
// A hook left unset is not a gap: the module then takes its own HTTP billing path,
// which is the correct behavior for a split deployment where the money layer is not
// co-resident.
func installBilling(deps cloud.Deps) {
if m := deps.Metering; m != nil && m.Enabled() {
aiobject.SetTierReader(m.Tier)
deps.Logger.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
}
fin := finance.Current()
if fin == nil {
return // money layer not co-resident (split-deploy); ai falls back to HTTP.
}
// Money is billed to the SUBJECT's wallet, inside the org's ledger.
//
// org = which ledger (the tenant's books)
// subject = which wallet in it (ai resolves it: a person => "org/name", an
// org-owned application/service key => the org's own account)
//
// So a personal account has a PERSONAL balance and a personal plan, and an org
// pays for what its applications and service keys spend — which is the product:
// sign up as yourself, then stand up an org whose users are your customers.
//
// Keying both hooks on the org collapsed every member onto the tenant's pool
// wallet: every new signup lives in "hanzo", so a brand-new $0 account read
// HANZO's balance and sailed through the gate — we enforced our own wallet.
//
// The invariant that must never break: the gate READ and the usage DEBIT key on
// the SAME wallet, or spend can outrun the balance that admitted it. Both use
// subject; keep them together. The gate reads a coarse cents balance (a >0
// threshold only); the DEBIT is 18-decimal-exact.
aiobject.SetBalanceReader(func(ctx context.Context, subject, namespace, currency string) (int64, error) {
bal, err := fin.Balance(ctx, namespace, subject, currency, false)
if err != nil {
return 0, err
}
return bal.Cents(), nil
})
// The DEBIT is exact: the module emits the cost as a decimal-USD string, parsed
// here to 18-decimal USD (1e-18) so a sub-cent call bills precisely and is never floored.
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
amt, err := money.ParseUSD(u.USD)
if err != nil {
return err
}
return fin.RecordUsage(ctx, types.UsageInput{
Org: u.Namespace, Subject: u.Subject, Amount: amt,
Currency: u.Currency, Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
})
})
deps.Logger.Info("ai prepaid gate wired to the in-process finance ledger (per-subject wallet, 18-decimal-exact, fail-closed)")
}
// dialTasks opens a client on the process's embedded tasks engine — the ONE durable
// queue — for the module's ai-ingest workflows. The engine binds loopback and shares
// cloud's trust boundary, so this dial is ungated; data isolation lives in the
// workflow INPUT (owner-scoped), which is why every org enqueues into the engine's
// always-registered `default` namespace rather than a per-org one (the embedded
// engine registers no others, and dialing an unregistered namespace makes the worker
// poll forever and silently forces ingest back inline).
//
// Before the engine is up — and if it failed to embed at all — this reports the
// module's own ErrTasksNotConfigured, which its ingest handler answers by running
// the ingest inline. That is the same fail-soft path an uninstalled dialer takes, so
// the durable plane is opt-in-by-availability and never a hard dependency.
func dialTasks(string) (tasksclient.Client, error) {
emb := cloud.EmbeddedTasks()
if emb == nil {
return nil, aiobject.ErrTasksNotConfigured
}
return tasksclient.Dial(tasksclient.Options{
HostPort: fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort()),
Namespace: "default",
})
}
+122
View File
@@ -0,0 +1,122 @@
package apps
import (
"context"
"encoding/base64"
"errors"
"testing"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/types"
)
// TestWireInstallsAIHostBindings walks the PRODUCTION path — Wire() then
// cloud.BuildDeps, exactly what every cmd/*/main.go does — and proves each host
// binding the inference module resolves is installed and dispatches to the real
// in-process implementation. The module's setters are package globals with no
// registry to inspect, so a cut that quietly stopped calling them would compile,
// boot, serve, and bill nobody; this is the assertion that makes that impossible.
func TestWireInstallsAIHostBindings(t *testing.T) {
// The ledger is a per-org SQLite file, and an encryption-capable build refuses to
// open one unencrypted. One deterministic key roots the at-rest layer for the
// process; the files themselves are fresh per t.TempDir().
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
t.Setenv("CLOUD_KMS_MASTER_KEY_REF", base64.StdEncoding.EncodeToString(key))
// Start from an empty module so nothing here can pass on a leftover hook.
aiobject.SetTierReader(nil)
aiobject.SetBalanceReader(nil)
aiobject.SetUsageRecorder(nil)
finance.Publish(nil)
t.Cleanup(func() {
cloud.RegisterBillingInstaller(nil)
finance.Publish(nil)
})
Wire()
if aiobject.BalanceReader() != nil || aiobject.TierReader() != nil {
t.Fatal("Wire() must only REGISTER the money bindings; they need the built deps")
}
cloud.BuildDeps(&cloud.Config{
Brand: "hanzo",
DataDir: t.TempDir(),
Enable: []string{"commerce"}, // money layer co-resident (the unified binary)
})
tier, balance, usage := aiobject.TierReader(), aiobject.BalanceReader(), aiobject.UsageRecorder()
if tier == nil {
t.Error("per-tier SKU gate reader not installed: every tier-gated SKU fails OPEN")
}
if balance == nil {
t.Fatal("prepaid balance reader not installed: the spend gate admits everyone")
}
if usage == nil {
t.Fatal("usage recorder not installed: completions serve free")
}
// The bindings must reach the SAME wallet in both directions. Credit a person's
// wallet inside an org ledger, then read and debit it through the module's hooks:
// swapping subject and namespace (the shipped bug this guards) would read the org
// pool instead, so the balance would not be the 500 cents deposited here.
ctx := context.Background()
fin := finance.Current()
if _, err := fin.Deposit(ctx, types.DepositInput{
Org: "acme", Subject: "acme/bob", Amount: money.FromCents(500),
Currency: "usd", Ref: "test-grant",
}); err != nil {
t.Fatalf("seed deposit: %v", err)
}
got, err := balance(ctx, "acme/bob", "acme", "usd")
if err != nil {
t.Fatalf("balance hook: %v", err)
}
if got != 500 {
t.Fatalf("balance hook read %d cents, want 500 — the gate is not reading the subject's wallet", got)
}
// The debit parses the module's decimal-USD string exactly, never floored to a
// whole cent. Two 1.005 USD calls take 2.01 off 5.00, leaving 2.99 — a floored
// debit would leave 3.00, and the gate's coarse cents read separates the two.
// That the balance moves at all is the same-wallet proof: a debit keyed on the
// org pool would leave this subject's 500 untouched.
for _, id := range []string{"test-call-1", "test-call-2"} {
if err := usage(ctx, aiobject.UsageEvent{
Subject: "acme/bob", Namespace: "acme", USD: "1.005", Currency: "usd",
Model: "zen", Provider: "hanzo", RequestID: id,
}); err != nil {
t.Fatalf("usage hook: %v", err)
}
}
got, err = balance(ctx, "acme/bob", "acme", "usd")
if err != nil {
t.Fatalf("balance hook after debit: %v", err)
}
if got != 299 {
t.Fatalf("balance after two 1.005 USD debits = %d cents, want 299", got)
}
}
// TestIngestDialerFallsBackInline proves the durable-queue binding keeps the
// module's fail-soft contract. The dialer resolves the embedded engine at dial time,
// so with no engine it must report the module's OWN ErrTasksNotConfigured — the
// sentinel the ingest handler matches on to run the ingest inline. Any other error
// would surface as a 5xx on a path that is supposed to degrade silently.
func TestIngestDialerFallsBackInline(t *testing.T) {
Wire()
if _, err := dialTasks("acme"); !errors.Is(err, aiobject.ErrTasksNotConfigured) {
t.Fatalf("dialTasks with no embedded engine = %v, want ErrTasksNotConfigured", err)
}
if _, err := aiobject.EnqueueIngest(context.Background(), "acme", &aiobject.IngestRequest{}, "en"); !errors.Is(err, aiobject.ErrTasksNotConfigured) {
t.Fatalf("EnqueueIngest through the installed dialer = %v, want ErrTasksNotConfigured (handler falls back to inline)", err)
}
}
+12 -5
View File
@@ -183,12 +183,19 @@ func init() {
})
}
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
// Enablement is a separate axis: cloud.Serve mounts only the specs cfg.Enabled(name)
// admits, so a STAGED subsystem is linked but inert until named.
// Wire composes the process: it installs the host bindings the co-resident
// inference module resolves (see ai.go) and returns every linked subsystem as a
// cloud.MountSpec, in mount order. The slice position IS the order: cloud.MountAll
// iterates it as-given, registering each subsystem's teardown as a zip shutdown hook
// so teardown runs in reverse (LIFO). Enablement is a separate axis: cloud.Serve
// mounts only the specs cfg.Enabled(name) admits, so a STAGED subsystem is linked
// but inert until named.
//
// The bindings are NOT a MountSpec on purpose: a spec is skipped whenever its name
// is absent from an explicit CLOUD_ENABLE list, and a money gate that silently stops
// being installed is a revenue leak, not a degraded feature.
func Wire() []cloud.MountSpec {
installAI()
return []cloud.MountSpec{
// embedded NATS :4222 + JetStream.
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
+66
View File
@@ -0,0 +1,66 @@
package cloud_test
import (
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/finance"
)
// The money plane the co-resident inference module gates on is installed by the
// composition root through cloud.RegisterBillingInstaller — package cloud hands over
// the built Deps and never imports the module. A cut that compiles but stops calling
// the installer is a revenue leak, not a build break, so these tests pin the two
// halves of the contract: BuildDeps calls it, and it calls it with everything the
// bindings need already in place.
// TestBuildDepsInvokesBillingInstaller proves BuildDeps invokes the registered
// installer exactly once, with the metering client built and the finance ledger
// ALREADY PUBLISHED — the ordering the tier read and the prepaid gate depend on. The
// ledger is asserted from INSIDE the callback: asserting it afterwards would pass
// even if the installer ran first.
func TestBuildDepsInvokesBillingInstaller(t *testing.T) {
finance.Publish(nil)
t.Cleanup(func() {
cloud.RegisterBillingInstaller(nil)
finance.Publish(nil)
})
calls := 0
var metering bool
var ledger bool
cloud.RegisterBillingInstaller(func(deps cloud.Deps) {
calls++
metering = deps.Metering != nil && deps.Metering.Enabled()
ledger = finance.Current() != nil
})
cloud.BuildDeps(&cloud.Config{
Brand: "hanzo",
DataDir: t.TempDir(),
Enable: []string{"commerce"}, // money layer co-resident (the unified binary)
})
if calls != 1 {
t.Fatalf("billing installer ran %d times, want exactly 1 per BuildDeps", calls)
}
if !metering {
t.Error("installer ran before the metering client was built: the per-tier SKU gate would never be installed")
}
if !ledger {
t.Error("installer ran before the finance ledger was published: the prepaid balance/usage hooks would never be installed")
}
}
// TestBuildDepsWithoutBillingInstaller proves an unregistered seam is inert, not
// fatal: a binary that links cloud without the composition root (cmd/kmsreseal, any
// test harness) boots normally and leaves the module's own HTTP billing path alone.
func TestBuildDepsWithoutBillingInstaller(t *testing.T) {
cloud.RegisterBillingInstaller(nil)
t.Cleanup(func() { finance.Publish(nil) })
deps := cloud.BuildDeps(&cloud.Config{Brand: "hanzo", DataDir: t.TempDir()})
if deps.Logger == nil {
t.Fatal("BuildDeps must still build deps with no billing installer registered")
}
}
+43 -77
View File
@@ -8,7 +8,6 @@ import (
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud/cek"
"github.com/hanzoai/cloud/clients/commerce/transport"
"github.com/hanzoai/cloud/clients/metering"
@@ -20,9 +19,7 @@ import (
"github.com/hanzoai/cloud/clients"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/gateway/edge"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/clients/s3admin"
"github.com/hanzoai/cloud/types"
)
// BuildDeps constructs the Deps used by every subsystem's Mount(app, deps).
@@ -96,7 +93,6 @@ func BuildDeps(cfg *Config) Deps {
// commerce URL yields a !Enabled() client, so the wrap is a transparent
// pass-through and a dev deployment is never blocked.
deps.Metering = buildMeteringClient(cfg, logger)
wireTierReader(deps.Metering, logger)
// AI (completions, WRITE) and Embed (embeddings, READ-ONLY) are DISTINCT
// credentials by concern: completions never ride the read-only publishable
// (pk-) key — the gateway 403s a pk- key on any write endpoint — so deps.AI
@@ -104,7 +100,7 @@ func BuildDeps(cfg *Config) Deps {
// least-privilege for a read-only call). Both meter through the ONE commerce path.
deps.AI = meteredAIClient(pickCompletionsClient(cfg, logger), deps)
deps.Embed = meteredAIClient(pickEmbedClient(cfg, logger), deps)
wireFinance(cfg, logger)
publishLedger(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
deps.VFS = pickVFSClient(cfg, logger)
deps.MQ = pick(cfg, logger, "mq", "MQ", cfg.MQZAPAddr, clients.MQRPCAt, clients.DisabledMQ)
@@ -127,9 +123,37 @@ func BuildDeps(cfg *Config) Deps {
}
deps.GatewayPolicy = gp
// Last: hand the composition root the built deps so it can bind the co-resident
// inference module's prepaid gate to the money plane above (metering client +
// published ledger). MountAll has not run yet, so the gate is bound before any
// subsystem — and long before any request — can read it.
if billingInstaller != nil {
billingInstaller(deps)
}
return deps
}
// billingInstaller is the registered bootstrap that binds cloud's money plane — the
// caller's commerce plan tier, their prepaid wallet balance, and the usage debit —
// into the co-resident inference module (hanzoai/ai). The composition root registers
// it; BuildDeps invokes it once, after the metering client and the finance ledger
// exist and before MountAll.
//
// The inversion is what keeps the inference module OUT of this package's import
// graph. Calling the module's setters from here made every package that imports
// cloud — most of clients/* — link the module's entire closure, ~1.5k packages, to
// hand over four functions over plain data. Same pattern as telemetryInstaller and
// kmsClientFactory. Exactly one registration.
var billingInstaller func(Deps)
// RegisterBillingInstaller installs that bootstrap. apps (the composition root, the
// ONE package that imports both cloud and the inference module) calls this from
// Wire(), before Serve. It is the ONE inversion point for the module's money seam;
// unregistered — a binary without the module linked — leaves the module's own HTTP
// billing path in place, unchanged.
func RegisterBillingInstaller(f func(Deps)) { billingInstaller = f }
// staticEdgePolicy projects the static env/flag edge config into the boot-default
// policy the edge.Store layers runtime overrides on top of. A disabled
// per-IP limiter (CLOUD_EDGE_RATELIMIT=false) maps to PerIPRPM 0 (a live no-op).
@@ -209,80 +233,22 @@ func boolStr(b bool, t, f string) string {
return f
}
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
// resolves the caller's commerce subscription tier through the SAME co-resident
// commerce client the metering gate bills over — in-process (the commerce transport) when
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
// leaving ai's standalone HTTP fallback in place.
func wireTierReader(m *metering.Client, log luxlog.Logger) {
if m == nil || !m.Enabled() {
// publishLedger constructs the ONE in-process finance ledger (per-org SQLite
// double-entry prepaid wallet) and publishes it for every money consumer to resolve
// by the narrow finance.Client: the edge meter, the admin credit grant, the
// entitlements standing read, and the inference module's prepaid gate (bound in the
// composition root — see RegisterBillingInstaller). Publishing before MountAll is
// what lets those consumers resolve it lazily, per request.
//
// No-op when the money layer is not co-resident (split-deploy): finance.Current()
// then stays nil and each consumer takes its own documented path — fail closed for
// the credit/standing reads, the module's own HTTP billing for the prepaid gate.
func publishLedger(cfg *Config, log luxlog.Logger) {
if !cfg.Enabled("commerce") {
return
}
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
return m.Tier(ctx, subject, namespace)
})
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
}
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
// the narrow finance.Client, and installs the embedded ai router's balance-read +
// usage-debit hooks so the PREPAID gate dispatches DIRECTLY to it — a typed in-proc
// call, no HTTP, no socket. There is NO exempt path (hanzoai/ai >= v1.805.8): every
// principal is gated on a positive prepaid balance, fail-closed. MUST run before
// ai.Mount (the ai gate reads the hook per request; the hook must be installed first)
// — which BuildDeps guarantees (deps are built before MountAll).
func wireFinance(cfg *Config, log luxlog.Logger) {
if !cfg.Enabled("commerce") {
return // money layer not co-resident (split-deploy); ai falls back to HTTP.
}
fin := finance.New(cfg.DataDir)
finance.Publish(fin)
// Money is billed to the SUBJECT's wallet, inside the org's ledger.
//
// org = which ledger (the tenant's books)
// subject = which wallet in it (ai resolves it: a person => "org/name", an
// org-owned application/service key => the org's own account)
//
// So a personal account has a PERSONAL balance and a personal plan, and an org
// pays for what its applications and service keys spend — which is the product:
// sign up as yourself, then stand up an org whose users are your customers.
//
// Keying both hooks on the org collapsed every member onto the tenant's pool
// wallet: every new signup lives in "hanzo", so a brand-new $0 account read
// HANZO's balance and sailed through the gate — we enforced our own wallet.
//
// The invariant that must never break: the gate READ and the usage DEBIT key on
// the SAME wallet, or spend can outrun the balance that admitted it. Both use
// subject; keep them together. The gate reads a coarse cents balance (a >0
// threshold only); the DEBIT is 18-decimal-exact.
aiobject.SetBalanceReader(func(ctx context.Context, subject, namespace, currency string) (int64, error) {
bal, err := fin.Balance(ctx, namespace, subject, currency, false)
if err != nil {
return 0, err
}
return bal.Cents(), nil
})
// The DEBIT is exact: the ai module emits the cost as a decimal-USD string, parsed
// here to 18-decimal USD (1e-18) so a sub-cent call bills precisely and is never floored.
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
amt, err := money.ParseUSD(u.USD)
if err != nil {
return err
}
return fin.RecordUsage(ctx, types.UsageInput{
Org: u.Namespace, Subject: u.Subject, Amount: amt,
Currency: u.Currency, Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
})
})
log.Info("finance ledger wired (per-subject wallet in the org ledger, 18-decimal-exact, fail-closed)", "dataDir", cfg.DataDir)
finance.Publish(finance.New(cfg.DataDir))
log.Info("finance ledger published (per-subject wallet in the org ledger, 18-decimal-exact)", "dataDir", cfg.DataDir)
}
// pick resolves one inter-subsystem client under the HIP-0106 wiring rule shared
+1 -1
View File
@@ -24,7 +24,7 @@ import (
// no principal, which answers "sign in to view billing". The proxy was calling itself.
//
// WHERE THE MONEY IS. Co-resident, the prepaid wallet lives in cloud's OWN finance ledger
// (clients/finance, per-org double-entry SQLite): wireFinance (build.go) points the ai
// (clients/finance, per-org double-entry SQLite): installBilling (apps/ai.go) points the ai
// prepaid gate's balance read at it, the edge meter debits it, and an admin grant credits
// it (clients/admin/core.grantDeposit prefers finance.Current() for exactly this reason).
// So the customer's balance is read from that ledger DIRECTLY — no HTTP hop, nothing to
+1 -1
View File
@@ -19,7 +19,7 @@ package entitlements
// THE ADDRESS IS LOAD-BEARING. A money gate that reads a different wallet than the
// debit writes is the bug this codebase has already shipped twice, both times by
// keying the ORG POOL: "every new signup lives in 'hanzo', so a brand-new $0
// account read HANZO's balance and sailed through the gate" (build.go wireFinance).
// account read HANZO's balance and sailed through the gate" (apps/ai.go installBilling).
// Read on the org pool, this paywall would admit every free signup in the shared
// org for as long as the platform's own pool is funded — a total bypass. So the
// credit leg reads principal.WalletOf's address and nothing else.
+1 -1
View File
@@ -13,7 +13,7 @@ package principal
// times the same way — a gate keyed on the ORG POOL while the debit spent a
// PERSON's wallet:
//
// - build.go wireFinance: "Keying both hooks on the org collapsed every member
// - apps/ai.go installBilling: "Keying both hooks on the org collapsed every member
// onto the tenant's pool wallet: every new signup lives in 'hanzo', so a
// brand-new $0 account read HANZO's balance and sailed through the gate."
// - middleware_billing.go identityFromCtx: "this gate checked the pool's balance
+4 -4
View File
@@ -18,8 +18,8 @@
//
// It composes three co-resident GLOBALS and owns no state of its own:
//
// aiobject.TierReader() — the caller's commerce plan tier (installed by wireTierReader)
// finance.Current() — the per-org ledger's windowed sum (installed by wireFinance)
// aiobject.TierReader() — the caller's commerce plan tier (installed by apps/ai.go)
// finance.Current() — the per-org ledger's windowed sum (published by cloud.BuildDeps)
// flags.Int(key) — the admin-editable per-tier caps (the platform-switch registry)
//
// The two knobs are platform switches, so admin.hanzo.ai renders and edits them live
@@ -89,8 +89,8 @@ func init() {
// Mount installs the rolling-cap reader. It registers no routes — the platform
// switches (registered in init) surface in the admin cockpit on their own. No-op when
// the tier or finance layer is not co-resident (standalone / split deploy): ai's hook
// stays nil, behavior unchanged. Runs after MountAll's prerequisites — wireTierReader
// + wireFinance have already installed the globals it reads.
// stays nil, behavior unchanged. Runs after MountAll's prerequisites — the composition
// root's installBilling has already installed the globals it reads.
func Mount(_ *zip.App, _ cloud.Deps) error {
tier := aiobject.TierReader()
fin := finance.Current()
+14 -19
View File
@@ -12,9 +12,7 @@ import (
"path/filepath"
"strings"
aiobject "github.com/hanzoai/ai/object"
tasksauth "github.com/hanzoai/tasks/pkg/auth"
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
tasksengine "github.com/hanzoai/tasks/pkg/tasks"
)
@@ -37,21 +35,21 @@ const durableGatedZAPPort = 9999
var embeddedTasks *tasksengine.Embedded
// EmbeddedTasks returns the ONE in-process tasks engine, or nil until
// wireDurableIngest has run (or if it failed to start). The Tasks HTTP/UI surface
// (clients/tasks, mounted at /v1/tasks/*) serves on THIS shared engine — there is
// exactly one engine per process, shared by ai's durable ingest AND the Tasks
// product surface, never a second Embed. The surface resolves it lazily (per
// request) because subsystem Mount runs during MountAll, before wireDurableIngest.
// wireDurableIngest has run (or if it failed to start). Every consumer resolves it
// through THIS accessor, lazily, per request: the Tasks HTTP/UI surface
// (clients/tasks, mounted at /v1/tasks/*) and the inference module's ingest dialer
// (apps/ai.go) both run on the ONE shared engine, never a second Embed — and both
// are wired before the engine starts, since Mount and Wire run ahead of it.
func EmbeddedTasks() *tasksengine.Embedded { return embeddedTasks }
// wireDurableIngest embeds the ONE hanzoai/tasks engine IN-PROCESS — the unified durable
// queue (there is no second async system; tasks/CONTRACT) — and injects a per-org
// loopback ZAP dialer into ai's ingest. A long ingest (github/crawl/s3) then runs as a
// durable workflow in the OWNER's namespace (CONTRACT §6: namespace maps 1:1 to org),
// tracked in the ONE Tasks product. In-process ZAP = mega fast, low latency/memory, no
// HTTP. Fail-soft by construction: any embed error leaves ai's dialer unset →
// EnqueueIngest returns ErrTasksNotConfigured → the handler runs ingest inline (always
// works). Called once, after MountAll (ai is mounted) and before Listen.
// queue (there is no second async system; tasks/CONTRACT). A long ingest
// (github/crawl/s3) then runs as a durable workflow in the OWNER's namespace
// (CONTRACT §6: namespace maps 1:1 to org), tracked in the ONE Tasks product.
// In-process ZAP = mega fast, low latency/memory, no HTTP. Fail-soft by
// construction: any embed error leaves EmbeddedTasks nil → the dialer reports
// ErrTasksNotConfigured → the handler runs ingest inline (always works). Called
// once, after MountAll and before Listen.
func wireDurableIngest(ctx context.Context, deps Deps) {
// A stable data dir the engine owns. Cloud's container is distroless (no /tmp), so
// Embed's default os.MkdirTemp("") fallback fails — pin it to cloud's data root.
@@ -77,11 +75,8 @@ func wireDurableIngest(ctx context.Context, deps Deps) {
return
}
embeddedTasks = emb
addr := fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort())
aiobject.SetIngestDialer(func(org string) (tasksclient.Client, error) {
return tasksclient.Dial(tasksclient.Options{HostPort: addr, Namespace: "default"})
})
deps.Logger.Info("durable ingest wired: in-process tasks engine", "addr", addr, "dataDir", dataDir)
deps.Logger.Info("durable ingest: in-process tasks engine up",
"addr", fmt.Sprintf("127.0.0.1:%d", emb.ZAPPort()), "dataDir", dataDir)
// Expose the SAME engine on a cluster-reachable, IDENTITY-GATED ZAP listener so the
// standalone tasksd's consumers (auto, hanzo-playground, platform) run their durable