feat/anon-pointer-position
218
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
91da40a5c3 |
apps: a seam that wraps nothing is a seam that never runs
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m42s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
zip refuses a program whose middleware has no routes beneath it, and the refusal
was right about three surfaces here. Nothing in this repo called Build() from a
test, so the refusal could only ever surface as a startup panic — or, for a
subsystem no test drove, as silence.
auditlog and catalog declared Bridge (and audit's noStore) on a group at their
own prefix while declaring the op on the App with its WHOLE path. The op is
therefore that group's SIBLING, not its child, so the group's subtree was empty
and the middleware could not run. Both packages' entire test suites were
panicking out of app.Test, which builds. Use is the verb that says the true
thing, and it needs no second copy of the prefix: cloud's scope bounds it to the
subsystem's declared subtrees, and a bare *zip.App treats root middleware as
live. The ops keep their exact paths — moving them onto the group with an empty
leaf would publish /v1/audit/ and /v1/catalog/, which neither API has served.
zen was worse and not the same defect. Its Claim gates ai's "/v1", it declares no
prefix of its own (Coresident), and plugin/zen fed manifest.PrefixesFor("zen") —
a ROUTING answer — into cloud.Plugin.Prefixes, which is the MIDDLEWARE grant. One
field was answering two questions, so dropping "/v1" from zen's row (right, for
routing: it duplicated ai's claim) silently revoked the gate, and MountAll refused
the mount outright. manifest.App.Gates states the second fact where the first
cannot, GrantFor reads it, and TestGrantMatchesPrefixesForRoutedApps keeps it from
becoming a second list.
crm was the same failure wearing an ordering convention. Its intake limiter used
to cover "everything registered after this line" — the public form plus the three
staff routes. Under lexical scoping it narrowed to the one route chained onto it
and the staff routes lost cover with no error anywhere. The covered routes are
composed BENEATH the limiter now, which states the coverage instead of implying
it. TestIntakeRateLimitScope was red before this and is green after.
compose_build_test.go is the gate that was missing: Build() through BOTH routers,
because MountAll hands a scope that can HIDE a seam a bare app refuses. It also
pins the rule itself, so a zip that stopped refusing the shape could not let the
seam rot back in green.
Deps: base v1.5.15 (the sqlite_math guard is a probe now, so `go build ./...`
needs no tags) and zip v1.24.2, plus iam v1.34.19 and o11y v1.5.57 restored —
|
||
|
|
7d106d962a |
forward only: undo the downgrades 394ba2e8 pushed, and land the base bump it claimed
|
||
|
|
f340b6cb72 |
dataroom: the room an agent can open, because a typed op is the only kind it can see
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/dataroom/* has served fourteen routes and reached no agent. An untyped route appends no op to zip's registry, and the MCP door renders tools FROM that registry — so the fleet answered tools/list with 570 tools and not one of them opened a data room. The hole was never the mount (dataroom mounts, and /v1/dataroom/health has been answering in production); it was that every route was an untyped relay. Ten of them are typed ops now — every JSON route on the admin surface, which is the whole demo surface: open a room, put documents in it, grant a party access, and list what exists. Each still relays the bundle's own (status, body); what changed is that it now carries an In and an Out, so the same declaration yields the tool, the OpenAPI operation, the SDK method and the CLI command. The package doc claimed NONE of these could be typed, on two premises the shared kit answers: that a relayed answer is opaque (it is not — the bundle's shapers are total and schema.go types them, which is what the models are), and that a typed error path would overwrite the bundle's envelope (it does not — BundleErr carries the bundle's status and BYTES). captable had already disproved both; this makes that the second use rather than the second copy, so Scalar, SizedIn, BundleErr and Envelope move to apps/goja, beside the bundle seam they serve. ScalarList is the one piece captable did not need. A bundle substitutes an EMPTY list for anything that is not an array, so an agent told allowList is a `string` sends one, the room discards it, and the call SUCCEEDS having ignored the access control — a link meant for one investor admitting everyone, reported as success. Declaring the array is what puts that failure out of reach. Four routes stay relays for reasons in the wire, each named at its registration: the upload takes the file itself as the body, the two /file routes answer with a byte stream, and the three public viewer routes have no principal to read. Proven: the demo flow end to end over the typed routes; the reads byte-identical to the bundle they replace; the cross-tenant link index still written, so a granted link still opens for an anonymous visitor; org scoping; the room's own refusal envelope intact; and the ten tools present with descriptions and schemas. The regenerated captable subset is operationId-only (40 lines, 0 schema changes) — pre-existing drift between the committed spelling and what zip v1.24.1 derives, corrected by regenerating from source rather than by hand. The same drift had left one captable test asserting a tool name nothing produces; it now asserts the derived one. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
26f69224fa |
projects: the operator vouch is an admin scope, not a membership
Binding a custom domain WITHOUT proving control of it — the bind lands
VERIFIED and routes immediately, and the "a host we operate" refusal does not
apply — is platform authority. The gate read
vouched := c.IsAdmin() || s.State.operatorOrgs[org]
and that second term is bare MEMBERSHIP. operatorOrgs defaults to the
deployment's own brand org (config.go getenv CLOUD_BRAND, brand.Default
"hanzo"), so the set is {hanzo} in every deployment, and `org` is the
IAM-validated effective org, gated upstream by isMember alone. Every staff
account whatever its role, plus anyone a brand-org admin ever invited, could
bind login.example-bank.com live with no DNS-01 proof: attacker content served
at any custom-domain customer whose DNS already points at our edge, and the
name denied to its rightful owner for good, since a verified row is first-come
and global.
vouches() now names the two grants, both admin-scoped:
SuperAdmin platform sudo (owner == the reserved admin org).
Cross-tenant by construction, so it vouches in ANY
org — the operator switched into a customer's org to
bind the domain it manages DNS for. Unchanged.
operator-org ADMIN the deployment named this org an operator AND IAM
says the caller administers it.
The second is a conjunction of two independently administered facts: a
capability the deployment grants to an org, and the role IAM grants inside it.
The set names an org; it never names an authority. The org-admin bit is asked
of the EFFECTIVE org — the same value SanitizeIdentity keys X-User-IsOrgAdmin
on — so the pair reads "admin OF this operator org" and never "admin of some
org I switched out of". Both bits are stripped on ingress and re-minted only
from validated claims.
Fail-secure: an issuer that stops signing the org role drops the operator-org
grant to a PENDING claim carrying the DNS challenge, the same self-service path
every other tenant takes. SuperAdmin onboarding never depended on that claim.
TestOperatorVouchNeedsAdminScope drives the real handler over four identities.
On the parent commit it fails with the exploit verbatim — a plain member's bind
of login.example-bank.com comes back {Status: live, Verified: true}. It also
holds the positive cases, so a fix that merely disabled operator onboarding
would not pass. TestOperatorVouchIsVerbatimEndToEnd keeps the verbatim-owner
pin and now has both callers be org admins, leaving the org name as the only
axis; it went from panicking to passing because it no longer builds the whole
surface.
The harness registers the ops it drives rather than calling routes(), which
composes only on the Router production gives it: routes() declares middleware
with Group(prefix, mw) and registers its typed ops on the App with full paths,
so on the bare *zip.App a test holds the prefix node has no routes beneath it
and zip refuses to compose. cloud.Listen mounts on a *scope, whose Use and
Group install at the root and gate by request path, so the same registration
composes there. What is driven is the production chain at the production paths:
cloud.Bridge parking the request, siteOf resolving the tenant, bindDomains
deciding.
The lifted prose and both published specs carry the corrected contract; the old
text told customers that membership of the operator org was the vouch. zipdoc
regenerates; the OpenAPI subsets cannot be projected for this app yet, so those
two files take the identical substitution zipdoc made.
apps/projects: 55 -> 58 tests passing, no test that passed on the parent fails.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
702c5aeaf4 |
risk: a search winner is a shape the organisation that asked for it can run
POST /v1/risk/search ranked model spaces against a tenant's own history and
answered with the one that fit. Nothing could promote it, and nothing could:
adoption refused a shape change, and a winner is a different shape by
definition. The stated replacement for Katib produced advice nobody could take.
A model value now carries the SPACE its masses describe, not only the masses,
and install REPLANTS — it builds that space, restores into it, and swaps it in.
Every gate the refusal used to carry still holds: the tenant comes from the
validated principal, the geometry seed is checked against the model already
running before anything is rebuilt, and the recorded shape is rebuilt and
compared by digest rather than trusted. A value recording no shape is refused
rather than defaulted, and a failed replant leaves the residency untouched.
The winner arrives as one of the organisation's own published values: the run
fits it once more after the grid — a sixty-fifth pass, gated and metered as one
— under that organisation's OWN geometry, because the grid's reference partition
is a constant and a model an organisation runs must partition the space in a way
an outsider cannot predict. Keeping all sixty-four fitted stores instead would
hold 21 MiB for sixty-three shapes nobody adopts.
Also:
- the shape is its own value, and the half of a config that belongs to the
state. The grid's candidate embeds it, the residency records it, a published
value stores it: one spelling, so adopting a shape cannot restate a policy.
- the resume row's shape is what the next process plants. Without it an
adopted shape was silently lost on every rollout and the organisation went
back to the default, warming, deciding nothing.
- the fold watermark travels with an adopted value. It is in the address for a
reason; leaving it behind meant a rollback would never re-read the fold it
skipped.
- snapshot+restore collapse to POST and PUT on /v1/risk/state/model: one
address for one kind of thing, the same collapse GET and PUT on
/v1/risk/policy already made. openapi/floor.json loses one PATH (the risk
product keeps all 31 operations).
- the value bound is sized on the widest shape the grid declares and MEASURED
at full occupancy (2,214,100 bytes), because a fitted sample understates a
busy organisation's tree by an order of magnitude.
- open() no longer reads the state believing the read plants a tenant's trees.
Measured against the pinned engine: it does not.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
2a836a71de |
plugins: declare the prefixes, in the eight that would panic the same way
CI/CD / image (push) Successful in 20m15s
CI/CD / gate (push) Successful in 24s
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / containment (push) Successful in 1m13s
CI/CD / rollout (push) Successful in 5m10s
CI/CD / reach (push) Failing after 1m56s
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
account went down because its plugin declared no Prefixes and MountPrefixes falls back to /v1/<name> — a path it does not serve — so the scope guarded a subtree with no routes and zip refused to compose. Same fault, same fix, in every other plugin that carries it. Found by predicate rather than by waiting for each outage: undeclared Prefixes AND a manifest row without /v1/<name> AND an app that calls app.Use(). All three are needed — 25 plugins match the first two and serve fine, because the panic only fires when a subsystem actually installs middleware at its scope root. admission bot dataset do graph knowledge leaderboard treasury Each now declares what the manifest already says it answers, which is what the host routes to it either way — so this changes no address, it only stops the scope guarding one that was never served. |
||
|
|
1d287efd01 |
account: declare the prefixes, or the scope guards a path with no routes
CI/CD / image (push) Failing after 28m52s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / containment (push) Successful in 2m10s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every account route answered 503 in production — /v1/keys, /v1/csrf, /v1/avatar
— because the plugin panicked at mount:
panic: zip: the group "/v1/account" declares middleware at scope.go:134
and no routes anywhere beneath it (via root -> /v1/account)
The plugin declared no Prefixes, and undeclared is not "no prefixes":
MountPrefixes falls back to the /v1/<name> convention (subsystem.go:78). Account
answers at NONE of /v1/account — its routes are /v1/keys, /v1/csrf, /v1/avatar,
/v1/orgs, /v1/embed and /v1/commerce/topup/*. So scope.Use installed the
subsystem's Bridge on a path with nothing beneath it, and zip refuses to compose
a program whose middleware can never run.
The same fallback bit analytics and entitlements before this; both carry the
same one-line fix, and this is it.
WHY THE TESTS DID NOT CATCH IT, which is the part worth keeping: they mount on a
bare zip.App, where Use attaches at the root and the root HAS routes. Production
mounts through a SCOPE. Same code, opposite outcome — so a green suite proved
nothing about the composition that actually ships.
|
||
|
|
0b0f2599ac |
billing: give the three unowned billing addresses an owner, or none at all
manifest/clients_test.go's ledger of addresses a first-party client asks for and
the fleet does not answer held three entries. All three are closed; the ledger is
empty.
GET /v1/billing/portal/methods — this is what made "card save is broken" true END
TO END. cloud's billing app serves the saved-card list by proxying here, and
nothing in the fleet served it, so the proxy forwarded a 404 verbatim and the list
rendered as "no cards saved" no matter how many cards were vaulted. Claimed on
commerce's manifest row and registered co-resident.
THE GATE, which is the actual work: PortalPaymentMethods keys tenancy on a
?customerId QUERY PARAM, so the chain has to pin the subject for both principals
that arrive. TokenRequired (not IAMTokenRequired) authenticates and resolves the
ORG from the gateway-pinned X-Org-Id for an IAM member AND for the raw service
token the proxy presents; PinBillingSubject is the IDOR control — it overwrites
every billing-subject key with the validated caller's own account.Payer subject
and drops ?org, passes the query through only for a bearer that constant-time
matches COMMERCE_SERVICE_TOKEN, and fail-closes anyone who is neither. The tenant
is never a caller-supplied field on either path.
DELETE /v1/billing/methods/{id} — 405 at the live edge: a customer could ADD a
card and never REMOVE one. billing registers the sub-resource on the same router
as the collection (the host claims a prefix for ONE app across every method) and
proxies it to commerce's DELETE /v1/billing/portal/methods/{id}, the target that
does not self-dispatch. The org comes from principal.Org — the VALIDATED
principal only, never readerOrg's service-token admission, because this is a
mutation and that is the rule createPaymentMethod and gpuCharge already follow.
The id is escaped into the upstream URL: it names a resource, not a route.
PATCH stays unserved — no client edits a card.
POST /v1/billing/payment — DELETED, not served. No app in either server repo has
ever registered it, in any commit, so the crypto top-up's recording step always
failed and a customer who had already sent USDC to the treasury got a 502; it was
501 besides, since TOPUP_RAILS is configured in no environment. There is nothing
to point it at: money-IN has one door (commerce's mint-gated POST
/v1/billing/deposit) and the fleet routes NO mint address at the edge — the only
two money-in paths manifest.Apps hands to an app are the card ones, both with a
server-authoritative amount. What the browser client sends here is a
client-supplied amount and a client-supplied subject, which is the exact shape the
mint gate exists to refuse. So the surface that existed only to call it goes with
it, and openapi/floor.json records the two-operation reduction next to its reason.
Tenant isolation: TestDeletePaymentMethod_TenantIsolation proves org A cannot aim
a delete at org B through the org, the id, or any subject key; the far side is
proved in commerce (payment_methods_tenant_test.go) for both handlers and both
caller profiles.
Needs hanzoai/commerce fix/billing-methods for the portal detach it proxies to.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
f6c9605bd7 |
zip v1.24.1: tests stop reaching through fiber, so they see what serving installs
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m38s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Upstream landed the v1.23 verb migration (Graft/Add/Mount folded into Use). This
is the half that was missing, and it is the half that made tests lie.
App.Test used to skip prepare, which installs the deferred projections — /mcp, the
OpenAPI document, the op-call plane, the plugin route. So those four addresses
answered 404 under test and 200 in production, and the papering-over was an
exported Prepare each caller had to remember. zip v1.24.1 makes Test prepare;
apps/ai's MCP door test passes because of that, not because of anything here.
414 call sites move from app.Fiber().Test(...) to app.Test(...) with
zip.TestConfig. That is the point of the escape hatch living on the concrete type:
reaching through it bypasses what App.Test does, so the tests most wanting to
exercise the real program were the ones that did not. Sites whose receiver is a
raw fiber app keep fiber's type — the two are not interchangeable and pretending
otherwise is how the first sweep broke things.
Also: the multi-line `Use(func(c *zip.Ctx) error {…})` literals in tests, which
the verb migration missed because they fail vet rather than build; and the last
`.Prepare()` calls, now that it is implicit.
iam v1.34.11 → v1.34.12.
Measured against upstream on the same host: 103 failing packages before, 97 after
— ZERO new, 6 fixed. The remainder is the macOS SQLCipher limit (no tmpfs for the
pure-Go codec), unrelated and unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
a361b88677 |
engine: an operation is named for its product, and its summary is for the caller
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
An operation id is the generated SDK METHOD NAME and the CLI COMMAND, and a summary is what the MCP tool list shows a model choosing between tools. This plane stated neither, so both were defaults, and both defaults were wrong in a way only a caller sees. zip derives an unstated id from the path, so the four ops published `get_v1_engine_status`, `get_v1_engine_models`, `get_v1_engine_model`, `get_v1_engine_system` — path mangling where the rest of the fleet publishes the product and the noun. The risk product's thirty-one operations are `riskScore`, `riskState`, `riskDatasets`, `riskLabelCoverage`; these are now `engineStatus`, `engineModels`, `engineModel`, `engineSystem`, which is also the rule this package already applied to its own SCHEMA names and only to those. A summary defaults to the first sentence of the Go doc comment, and a Go doc comment opens with the Go IDENTIFIER — so the published summaries read "Status reports whether the engine deployment is reachable", "Models lists the models the engine serves", "Model reads one model's load state". A Go symbol name was the first word a CLI user, an SDK reader and a model picking a tool saw. Each op now states a summary written in the imperative for the person calling it, and the doc comment stays a Go doc comment that zipdoc still lifts as the description: two audiences, two sentences, one declaration. FORWARDS-ONLY, and it costs nothing: this plane has no customers on it. No alias, no redirect, no compat shim. Regenerating from source changes exactly four operation ids. No path is added or removed, no (path, method) pair moves, no schema changes, and openapi/floor.json is byte-identical because the operation count did not move. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
1b8b76ed26 |
ml: delete the /v1/train facade — the CRDs behind it are not served
CI/CD / reach (push) Failing after 54s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m10s
CI/CD / image (push) Successful in 19m4s
CI/CD / rollout (push) Successful in 6m20s
Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/train/* was a thin proxy onto two Kubeflow CRDs that this cluster does not
serve: trainer.kubeflow.org/trainjobs and kubeflow.org/{experiments,trials}.
GET /v1/train/health answers 503 degraded in production right now
({"crds":{"experiments":false,"trainjobs":false},"status":"degraded"}) with
nothing in cloud changed — the CRDs were retired underneath it. Ten operations
go, and with them the degraded door.
Measured before deleting, cluster-wide: zero TrainJobs, zero Experiments, zero
Trials, and no ClusterTrainingRuntime for a TrainJob to reference. The katib
half could not have worked at all — katib's admission webhook requires the
namespace label katib.kubeflow.org/metrics-collector-injection=enabled,
ensureNamespace writes only {managed-by, hanzo.ai/org}, and no namespace in the
cluster carries it. So POST /v1/train/experiments took the billing gate, created
an Experiment, and katib never admitted a Trial.
There were also TWO doors onto one TrainJob CRD: this one and the hanzoai/ai
broker at /v1/finetune/*, which has the product around it (presets, HF pickers,
status polling, deploy-to-serving). One door survives.
KServe STAYS. /v1/ml/models is the only path in the estate that serves a
classical artifact end to end, and it is proven: POST /v1/ml/models with an
sklearn joblib -> 201, the storage-initializer pulls the model, then POST
/v1/ml/models/{name}/predict -> 200 with correct predictions on the
kserve-mlserver runtime. GET /v1/ml/health is 200 today. Its runtime-capacity
clause and every serving test are untouched.
openapi/floor.json needs NO edit on this base: the shrink guard is a FLOOR, and
main's committed floor (1684 paths / 2336 operations) is already below the
post-deletion document, measured at 1689 / 2341. The guard still bites — raising
the floor above the real count fails TestFleetIsTheWeaveOfItsApps with the same
"THE PUBLISHED SURFACE SHRANK" report, which is how these numbers were read.
(An earlier pass here lowered a floor that ALSO carried a per-product map; main
has since dropped that map, so the rebase takes main's shape unchanged.)
The billing-gate integration tests keep
their coverage by exercising the surviving create (POST /v1/ml/models) — the
gate is the shared create() body, not a per-kind one.
Also repointed every pointer that named the deleted route, so none dangles:
apps/engine's intentRefused reason and LLM.md (now /v1/finetune/jobs), spend.go's
routing-union example, apps/platform/drift.go's analogy, and the mutation in
scripts/mutate.py whose anchor line and target test are both gone (it would have
reported ANCHOR-MISS).
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
e16e60e522 |
Merge remote-tracking branch 'origin/main' into HEAD
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
# Conflicts: # openapi/floor.json Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
94df22e154 |
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # openapi/floor.json Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
b0b66440a0 |
Merge remote-tracking branch 'origin/main' into blue/model-value
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
b6199b0c55 |
Merge origin/main into blue/model-value
A sibling dissolved PUT /v1/risk/state/appetite while this branch was open (the decision regime has one address now), so typed.go conflicted on the op that used to sit between `state` and `snapshot`. Resolved by taking MAIN'S typed.go whole and re-applying this branch's five changes onto it, rather than by editing the conflict markers: the incoming change deletes an op, and a hunk-level resolution is how a deletion gets silently un-deleted. zipdoc_gen.go is generated — resolved by regenerating from the merged source, never by merging the artifact. Same for openapi.yaml and plugin/risk/openapi.json. Verified after the merge: 11 operations before and after, floor.json identical to main (risk 31, paths 1695, operations 2351), and main's own new gates — verb_test and learn_cost_test — pass beside this branch's. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
019582727d |
spec: the published document catches up to two money-mint removals it never recorded
CI/CD / containment (push) Successful in 1m7s
Hanzo CI/CD / cicd (push) Successful in 2m6s
CI/CD / gate (push) Successful in 2m7s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
Regenerating every subset from source is the drift gate, and it turns up drift in two apps beyond the ones whose prose this branch fixes. Both are real, and both are in the direction of the published document being AHEAD of the code: it advertises money fields the runtime stopped returning. |
||
|
|
89396e35f3 |
risk: a model is a value, named by its content, and the caller stops carrying it
THE MODEL WAS A PLACE. The `model` table is keyed on the tenant and written ON
CONFLICT DO UPDATE, so it is ONE CELL per organisation and every write destroys
the state before it. That single fact was three separate open problems: there was
nothing to roll back TO, so rollback needed an op that shipped the masses out to
the caller and an op that took them back in; two fitted models could not both be
named, so champion-and-challenger had nowhere to live; and no decision could say
which model produced it.
A content address ends all three. The value's name is a pure function of what
makes two models answer the same event differently — the shape, the geometry seed,
the position in the window, the threshold, the masses as IEEE-754 bits, and the
FOLD WATERMARK, which is not redundant with the mass count: two models with
identical masses reached by different routes disagree about what is left to fold,
and one will re-teach history the other will not.
THE IN-PROCESS MODEL STAYS MUTABLE, and that is the design. Measured: one encoded
value is 466 KiB at the reachable shape — 25 trees of 511 nodes over two windows,
every mass carrying a full mantissa because folding blends them. The sweep writes
every 500 events or every 30 seconds, so "a value per write" is 56 MiB an hour per
active organisation to record a counter going up. So identity is a SUCCESSION OF
STATES: the `model` row stays a place and its whole job is resuming a killed
process, and `published` is append-only, addressed by content, retained under a
budget stated in BYTES (10 values, following policyBudget's reasoning).
WHAT THIS DELETES. riskSnapshotBody is gone from the wire in BOTH directions, with
its two conversions. Publishing answers with a NAME; adopting takes one. The
masses never leave the organisation's own store, so the caller stops being the
custodian of a tenant's model — the engine's own Restore asks for exactly this
("it belongs where the tenant's own data belongs, and sealed if it travels"). Two
tests changed from refusing a threat to proving it unreachable: a caller can no
longer describe a model instead of naming one, so there is no body to compose and
no seed to choose. Rollback is naming a prior address; what the working state
descends from is DERIVED from its mass count, so rolling backward is right for
free where a stored head pointer is exactly what would fall out of step.
A SCORE NOW NAMES THE MODEL SPACE IT RAN IN. The shape is cached on the residency
and read in the same critical section as the verdict, so it costs nothing and
cannot cite a space the score did not run in. It is deliberately NOT an address:
the masses at the instant of a score are counters between two published values, so
citing one would claim that value produced this score. The shape, the policy
version and the event's own time are what IS true, and the value history's clock
brackets the decision from there.
ISOLATION, MEASURED RATHER THAN ASSERTED. The address deliberately omits the
organisation — a name that must be unguessable is obscurity, not isolation. Two
mutations were run and two drafts of the test comment were wrong before they were:
the per-organisation FILE and the `tenant = ?` row predicate are two layers and
EITHER ONE ALONE HOLDS, so a foreign org handed a real address resolves nothing
through all three doors with either layer removed. The single-layer regression is
caught where the file layer is already absent by design — two brands' identically
named organisations share one file — and that test fails on that one mutation
alone. The finding underneath inverts the obvious reading: the per-org file is not
what makes this isolated; the row predicate holds on its own.
11 operations before, 11 after: no path moved and the floor is untouched. The two
paths SHOULD collapse into one (/v1/risk/state/model, POST to publish and PUT to
adopt) — that is the prefix plane's call, not this commit's.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
1035d47970 |
risk: learning is a transformation, a verdict is a query, and learn no longer does both
POST /v1/risk/learn recorded a batch, trained on it AND answered the model's
verdict on every event. Three things under one name: you could not observe
without training, and could not train without being answered.
An observation is a VALUE the plane records; learning is a TRANSFORMATION over
observations; a verdict is a QUERY against the result. POST /v1/risk/score is
already the query and is already pure. So learn drops the verdict and answers
`learned` — how many events the model actually learned from.
WHAT IT COST TO CARRY. plane.learn called the engine twice per event, Inspect for
the response's verdict and Assess for the counters, and both enter the engine's
judge: two projections of the point at three aggregate reads each, and two walks
of the forest. Above the cut both also ran the counterfactual attribution, a
further walk per dimension over nine dimensions. Assess's own return was
discarded, so the attribution was computed twice and thrown away once.
MEASURED, and stated as measured (learn_cost_test.go, BenchmarkLearn):
before after
batch 8 31.5 µs/event 27.8 µs/event -12%
batch 128 20.0 µs/event 17.5 µs/event -12%
batch 128 6717 allocs 6169 allocs -8%
A tenth, not a half: the durable record and the aggregates are the larger part of
what a caller waits for, and the attribution is reached only by the share of the
stream the appetite admits — one per cent by default.
NOTHING DEPENDED ON THE SYNCHRONOUS VERDICT. No CLI command references risk, no
SDK carries a risk client, and the one in-process consumer of a verdict is
cloud.Decide, whose scorer is never installed outside tests (SetRiskScorer has no
non-test caller), so every question it asks answers {allow, scorer-absent}. The
live plane reports one resident model built once. Observe-and-judge in one round
trip is now a COMPOSITION and the published prose says which order: score first,
then learn, so the verdict is the model's opinion of an event it has not yet
learned from.
A DUPLICATE IS NOW WHOLLY INERT. It moved nothing before and was still judged;
now it costs no model work at all, is not counted in `learned`, and is not
metered — the meter runs on what was DONE, which is this app's own stated rule
for the gate/meter pair. The gate still bounds the batch the caller stated,
because how much of it is new is unknowable until the record is written.
TestPlane_TheVerbsStaySeparate (verb_test.go) walks the package and fails if
learn calls Inspect or score calls Assess. It is structural because the braid is
invisible behaviourally: with Inspect put back, every other test in this package
still passes.
zipdoc regenerated; plugin/risk/openapi.json and openapi.yaml rewoven
mechanically (make -f mk/fleet.mk openapi-weave, exit 0). riskScoreOut, riskCause
and riskValue stay in the document — score still answers them; they are simply no
longer reachable from riskLearnOut. Package green under -race.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
523f98667a |
risk: the decision regime has one address, and a write there answers the policy it wrote
The regime was written at PUT /v1/risk/state/appetite and read at GET /v1/risk/policy — two addresses for one plane, the writing one named after the mutable spot the regime happened to sit in. `state` is literally the word for that spot; a regime is not a spot. It is three numbers an organisation adopted, at a time, by a named identity, kept under a version forever after. Worse than the second address is what the write ANSWERED: riskModelState, fifteen fields describing the whole MODEL — its shape, what it had learned, whether it was warm, the threshold in force, the realised share beside the stated one, every refusal by reason, every feature that read blind, the fold coverage of the event surface, and the aggregate strain. A call that changes three numbers knows none of that. It was reporting a model it happened to be holding a lock on, which is the same braid, one level up, as the regime living on the learned state's row — the defect the versioned policy record was cut to fix. So the plane has ONE address and both verbs answer riskPolicyOut. plane.appetite returns the VERSION it left in force and nothing else: the two calls to r.mod.State and r.vel.strain are gone from the policy write, so a policy write no longer computes the model value at all. policyOut is the one projection both verbs render through, and the version in force is a PARAMETER to it — a write knows what it enacted from inside the lock it enacted under, and re-reading it there would let two concurrent restatements each report the other's version. There is no `minted` flag. plane.enact is idempotent on the regime, so a restatement answers the version already in force and equality of the VALUE is the signal. A boolean about the operation is a second thing to keep true beside the value that already says it. The published surface SHRINKS by one path and holds its operation count: the write joined the address that already existed instead of keeping a second one. Mechanically: paths 1696 → 1695 (-/v1/risk/state/appetite) operations 2351 → 2351 (PUT moved onto /v1/risk/policy) openapi/floor.json is lowered in this commit because the weave refuses a shrink otherwise, which is the gate working: a reduction is reviewed next to its reason. The op's published schema description loses 27 lines of riskModelState / riskSurface / riskAggregates prose it had no business publishing. Nothing consumed the old address. Swept every checkout under ~/work: the only references outside apps/risk's own tests were the derived specs. Live, PUT /v1/risk/state/appetite is routed and GET /v1/risk/policy is 404 — the policy plane is merged but not yet deployed — so this is the one moment the consolidation costs a deployed client nothing. Per-organisation isolation is untouched and proven through the NEW address: TestPolicy_HistoryIsPerTenantOverTheWire and TestPolicy_TwoBrandsShareAFileAndNotAHistory both state their regimes at PUT /v1/risk/policy and both still see nothing of the other organisation. Mutation table — each named test RED under the defect, GREEN after revert: M1 re-register PUT /v1/risk/state/appetite TestPolicy_HasOneAddress RED M2 put a model field back on the answer TestPolicy_AWriteAnswersThePolicyAndNotTheModel RED Verified: go build -tags "sqlite_purego sqlite_math_functions sqlite_fts5" ./... = 0; go vet apps/risk = 0; go test apps/risk = ok; -race = ok (23s); zipdoc -check ./apps/risk/ = 0. Fleet-wide zipdoc -check reports the same 16 stale/missing generated files before and after this change (measured by stashing it), so this adds no drift: apps/risk is not among them. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
caa1a81f07 |
dataset: the plane publishes under /v1/risk, the product its rows feed
The dataset plane addressed /v1/ml/datasets, beside the KServe model-SERVING
plane's own /v1/ml/health and /v1/ml/models — one prefix, two products.
openapi.Product reads an operation's product off the FIRST /v1 segment and
nothing else, so `ml` published 14 operations that were seven serving ops with
customers on them and seven dataset ops whose rows feed the risk model, which
learns in-process from the org's own events and is never served by KServe.
Moves the five paths to /v1/risk/datasets and renames the seven operation ids
and nine schema names to the risk face, so the SDK method, the CLI command and
the generated type each name the product they belong to. Fourteen of the fifteen
Go types take the bare risk<Noun> name; the dispose pair carries the noun it
disposes of, because apps/label already publishes riskDisposeIn for LABEL
disposal and the fleet's schema namespace is flat.
The tag was never the product: openapi.Fold assigns op.Tags from the router
projection, so zip.WithTags("ml") had been inert. It now reads "risk" too, so a
declaration and a projection do not disagree.
/v1/ml is untouched. Its four serving paths (7300 bytes) and its three schemas
(1533 bytes) are byte-identical, and regenerating the fleet document from source
moves exactly the seven dataset operations and the nine dataset schemas: 2350
operations, 1695 paths and 2060 schemas before and after, with every other
triple unchanged.
No alias and no redirect from the old prefix, because nothing calls it: a sweep
of every git checkout under work/hanzo finds /v1/ml/datasets named only in this
repo's own generated artifacts and prose. No client, SDK, CLI or MCP consumer
names it.
The floor ratchet refuses a shrink, so `ml: 14 -> 7` is lowered here in the same
commit that moves the routes; `risk` rises 23 -> 30 on the same regeneration.
address_test.go makes the address a gate rather than a third recollection after
label and reference: the product, the operation-id prefix and the schema prefix
are each asserted off the published projection, and each assertion fails when
the projection is empty, so none of the three can pass by examining nothing.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
d0a7a08db5 |
ml: the serving probe reports whether there is a runtime to run a model on
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
kserve admits an InferenceService whose model format no ClusterServingRuntime supports and then never schedules it, so /v1/ml/health answered 200 while every deploy hung. A served CRD is not capacity, and the probe read only the CRD. health now takes the cluster-scoped coordinate the plane needs at least one of (the zero GVR for a plane with no such fact — training carries its own images) and reports the count as its own field. An unreadable list reports the read error instead, because a missing grant is a broken probe and not an empty cluster, and the two call for different acts. scripts/mutate.py carries four rows: dropping the clause, folding the read error into the count as zero, asking capacity of training, and reading the runtime kind at the InferenceService's v1beta1 instead of v1alpha1. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
abc030e2f1 |
merge blue/risk-policy: the decision regime is durable on its own terms, versioned, and cited by every score
An organisation that took its model out of shadow BEFORE the model had learned
anything was told live=true and had nothing written down. The regime lived on the
same row as the learned state, and that row's writer declines to write while the
snapshot holds no learned mass — correctly, because there is no state to lose. So
PUT /v1/risk/state/appetite answered 200, reported live, and persisted nothing;
this binary deploys Recreate at one replica, so the next rollout rebuilt from
defaultConfig — shadow — and the model decided nothing. No error, no log, nothing
to alert on: a model silently disarmed, on a routed door.
Two conflicts, both resolved by REGENERATING rather than choosing a side:
openapi.yaml and openapi/floor.json are derived, and the branch was cut when the
fleet had 1684 paths. `make -C apps/risk describe` + the weave produce 1696 paths
and risk 24 operations — main's 1695/23 plus this branch's one op, GET
/v1/risk/policy. apps/risk/zipdoc_gen.go and plugin/risk/openapi.json regenerate
byte-identical to the branch's, so the projection did not drift.
One semantic conflict: `plane.close` gained the shutdown window on main
(
|
||
|
|
801b402b15 |
dataset: drop the last stale MCP catalogue
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
plugin/<app>/mcp.json stopped being a generated artifact when the door started asking each subsystem for its tools at the moment it is asked (package fleet, mk/plugin.mk describe). The committed copies were the exact hazard that change removed: plugin/o11y/mcp.json held 12 tools while the o11y binary served 365, and nothing compared them. plugin/dataset/mcp.json was the last one left — no go:embed names it, no Go source opens it, and every remaining mention in the tree is prose recording that the mechanism was retired. Removing it leaves 0 of 124 app dirs carrying one, so "the tool catalogue is not an artifact" is a property of the tree rather than a sentence in a comment. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
53ea52d795 |
reference: the projection and the document say the address the router serves
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The app was renamed to /v1/risk/reference — openapi.Product reads the product off the first /v1 segment, and these six operations are the risk product's — but the committed projection and the woven document were generated before the rename and still published /v1/ml/reference. manifest's TestEveryServedPathReachesTheAppThatServesIt is the gate that caught it: four paths the fleet published and routed to the /v1 catch-all instead. Regenerated plugin/reference/openapi.json from the app's own live router and re-wove openapi.yaml from the subsets. The delta is exactly the four reference paths; no neighbour moved. plugin/reference/mcp.json goes with it. The tool catalogue stopped being a generated artifact when the door started asking each subsystem for its tools at the moment it is asked (package fleet) — 122 of 124 apps ship no such file, and a stale one beside a regenerated projection is a second answer to a question that has one. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
30fdc23528 |
Merge remote-tracking branch 'origin/main' into blue/reference
# Conflicts: # manifest/order_test.go # openapi/floor.json Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
0077aeb4d9 |
Merge remote-tracking branch 'origin/money/no-automatic-issuance' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
22f215dd89 |
Merge remote-tracking branch 'origin/main' into blue/label
# Conflicts: # openapi/floor.json Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
ecd5bdd874 |
dataset: regenerate the subset and the fleet spec for the stated degradation
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
`oversize` is on the wire, so it is in the document: the app's own subset, the prose map and the woven openapi.yaml every SDK repo pulls. Generated by `make -C apps/dataset describe` + `make -f mk/fleet.mk openapi-weave OUT=openapi.yaml` — no hand edit. No new operation, so the floor is unmoved. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
077fce140b |
money: credit is issued by a human, so every automatic path goes
An hourly goroutine in authors deposited credit with nobody in the loop, and a
GET on three surfaces accrued-and-paid on read. Both are gone, along with the
capability that made them one line each.
- apps/authors/scheduler.go, sweepAndPayout, autoPayoutAuthor — the unattended
hourly accrue+pay loop, default ON, no env var, no route, no human.
- the lazy sweep on GET /v1/authors, /v1/affiliates, /v1/affiliates/me and
/me/earnings. Reads read; the admin POST sweep still accrues.
- payout settlement in both programs. A payout RECORDS what is owed, for every
method including credits; a human settles it. Accrual — the product — stays.
- treasury.Reserve/Credit returned backed=true when unmounted, and `mounted` is
a package global, so in one-binary-per-app it was ALWAYS nil in callers: every
"reserve-backed" payout was an unbacked mint that logged itself as reserved.
With settlement gone it has no callers, so it is deleted rather than fixed.
- POST /v1/admin/credits — a second admin mint with no cap and no positivity
check, whose audit did not fail closed. core.ApplyGrant is the one door: it
caps, rejects non-positive amounts, checks the org, and refuses without a
durable audit store. The relay and its wire client are deleted.
- payout.Client.Deposit, the ONE money-in primitive all three programs shared,
and the deposit method on each program's seam. The seams now carry a single
read, matching referrals: reviving a mint has to start by re-declaring the
capability, in front of a test that says no.
- the published POST /finance/starter op, advertising a grant deleted in
|
||
|
|
f62a85264d |
ai v1.832.19 — off the retracted .18, and give the referral payout its ref
main was pinned to hanzoai/ai v1.832.18, which is RETRACTED. That version seals the secret MASK as a provider key: the admin API returns "***" for a stored secret, so saving a provider form without touching the key field seals the literal "***" into KMS under the provider's own name — and because ai resolves KMS-first, the store then answers "***" for every read, outranking the env var that was serving the real key. The provider stops authenticating while its row still looks correct. v1.832.19 carries the guard. .19 also brings: secrets resolved from the EMBEDDED in-process KMS (this binary's own apps/kms) instead of over HTTP to the standalone deployment, which had never worked — 404 on the path it used, 401 on the correct one; /v1/provider-flags renamed to /v1/models/providers and derived from the served catalog, so the provider set and the model list cannot disagree; and a model family is now controlled from admin.hanzo.ai rather than only from deployment env. apps/referrals did not compile on main: payout.Deposit gained a required `ref` (commerce guards on it so a retried payout credits AT MOST ONCE) and this caller was not updated with it. A referral pays TWO wallets, so the referral id alone would name both and commerce would dedupe the second against the first — the referee's bonus would silently never land. bonusRef(id, side) makes each credit its own event while staying stable across retries. The published spec is regenerated: /v1/provider-flags is gone from openapi.yaml and plugin/ai/openapi.json (the .18 pin never regenerated it, so the drift gate was red), and the retired provider-flags product is dropped from the floor. Verified on Linux (macOS has no tmpfs for the pure-Go SQLCipher codec, so the store-backed suites cannot run there): apps/referrals, apps/ai, apps/kms and openapi all green. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
7783c19139 |
avatar: regenerate the API surface the new routes belong to
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m5s
CI/CD / image (push) Failing after 6m47s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The drift gate regenerates every app's spec subset FROM SOURCE and fails on any diff, so adding /v1/avatar without re-running it turned CI red — the release train's image job exited 1 after 6m29s and no cloud image was published. Regenerated, not hand-edited: zipdoc lifts the doc comments into zipdoc_gen.go, `describe` projects account's own subset, and the weave proves the fleet spec equals the sum of the subsets. floor.json moves by exactly what was added — +2 paths, +2 operations, one new `avatar` product with 2. |
||
|
|
c587fcd07a |
dataset: cloud.Listen, not cloud.Serve — main could not build an image
Hanzo CI/CD / cicd (push) Successful in 1m7s
CI/CD / gate (push) Successful in 1m36s
CI/CD / containment (push) Successful in 2m4s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
plugin/dataset/main.go called cloud.Serve, which does not exist. Every other
plugin calls cloud.Listen. The image build enumerates the manifest and compiles
each plugin in turn, so this failed the whole build at the dataset step:
plugin/dataset/main.go:29:18: undefined: cloud.Serve
That means no cloud image has been buildable since
|
||
|
|
d753e6c73d |
risk: the decision regime is durable on its own terms, versioned, and cited by every score
An organisation that took its model out of shadow BEFORE the model had learned
anything was told live=true and had nothing written down. The regime lived on the
same row as the learned state, and that row's writer declines to write while the
snapshot holds no learned mass — correctly, because there is no state to lose. So
PUT /v1/risk/state/appetite answered 200, reported live, and persisted nothing;
this binary deploys Recreate at one replica, so the next rollout rebuilt from
defaultConfig — shadow — and the model decided nothing. No error, no log, nothing
to alert on. A model silently disarmed, on a routed door.
The two facts are decomplected. The regime is now its own append-only versioned
record on the tenant's own shelf, written BEFORE anything in memory moves, so a
policy that cannot be written down is refused rather than answered from state the
next rollout will undo.
A regime is a VALUE: a version is minted only when the numbers CHANGE, so a
version means "the Nth distinct policy this organisation adopted" rather than "the
Nth time somebody pressed save", and a client that restates its config on every
deploy is free rather than the cheapest way to fill a disk.
Every score now cites the version it was decided under. Cut is derived from the
appetite that version states, so without the citation a restated appetite made
every earlier decision unreconstructible — the threshold it was measured against
no longer existed anywhere. GET /v1/risk/policy reads the history back.
Bounds, both per tenant and both on the tenant's own table:
RATE at most 24 distinct regimes per rolling 24h, refused past it with the
organisation's own bound named and the regime in force untouched.
TOTAL 381 versions, which IS 256 KiB divided by a measured worst-case row.
At the ceiling the oldest is disposed of and the number disposed of is
REPORTED, derived from the lowest surviving version so it cannot drift.
A regime that predates this record is ADOPTED as version 1 on first residency.
Without that, resolving the regime only from the new record would have returned
every already-live organisation to shadow on the first rollout after this ships —
causing the very defect being fixed, to every tenant at once.
The bounds on review and sample had two spellings, one at the op and one in the
plane. The op's copy is deleted; admitRegime is the one door, at the same
strictness the published contract always had.
Two test gates widened, because both could have been passed by looking at
nothing:
- TestOps_EveryOpIsAdmittedAndPriced parsed typed.go alone, so an op declared in
any other file was admitted and priced by nobody's assertion. It now parses
the package, matches on the RECEIVER TYPE (the plane carries score/learn/
state/appetite too), and fails when a registered op is declared nowhere.
- the per-tenant history assertion over the wire cannot observe the query's
tenant predicate: two orgs are two FILES. The predicate is load-bearing where
two brands share one file, and TestPolicy_TwoBrandsShareAFileAndNotAHistory is
the test that fails when it is dropped.
Eleven mutations applied, each named test red under the defect and green after
revert.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
e068e72ac9 |
merge main: the dataset plane landed beside ml; label stays under the risk product and keeps its place before the bare /v1/risk prefix
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
d94c0510a4 |
label: the ground-truth plane addresses under the product it belongs to, and its counts become byte bounds
THE ADDRESS IS THE PRODUCT. openapi.Fold takes an operation's product tag from the first /v1 segment of its path and nothing else (openapi.Product); a per-op zip.WithTags names a different axis and cannot override it. The seven ops were addressed /v1/ml/labels, so seven compliance operations — their own writers (commerce adjudicates the dispute, the compliance face closes the case, an analyst files the review), their own five-year retention floor, their own per-tenant file — would have been published as part of the KServe model-SERVING product, which is four paths and live with customers on it. Nothing in the fleet would have said so: the floor ratchet reads `ml: 7 -> 14` as growth, because it refuses a shrink and only a shrink. It is the same mistake apps/risk's own manifest row already records having made and corrected once, one layer up. So: /v1/risk/labels, tag risk, every operation id and schema name risk-prefixed (riskLabelEvent and not riskEvent — apps/risk publishes a riskEvent already, and it is a scored decision rather than a judged one). floor.json returns ml to 7 and raises risk to 17. The manifest row precedes risk, whose prefix is the bare /v1/risk, and TestEveryServedPathReachesTheAppThatServesIt proves all six paths reach label over the real fleet router rather than a comment claiming they do. address_test.go walks the live projection — the same openapi.FleetSpec that writes the committed subset — so a route re-addressed into somebody else's product fails at the plane. A BOUND ON COUNT OVER CALLER-SIZED VALUES IS NOT A BOUND. maxResolve capped a resolve at 500 named events and nothing capped a subject: the rows were bounded and the bytes were bounded only by the edge's BodyLimit, which is a fact about the deployment. Each subject is then amplified below the door — a dedupe key, a grouping key, one bound parameter per event in a statement against a single-writer file. The write door had the ceiling all along (admit, subjectMax); the read doors, added after, did not, and nothing compared them. There is now ONE spelling of each ceiling — admitSubject, admitKind, admitSource, admitEvidence, and instantMax inside stamp(), which is the one parser every time field passes — and every door asks it. So `count × ceiling` IS the byte bound of everything this plane binds, holds and stores. An unknown kind or source is refused on the READ path too: it can only ever match zero rows, so refusing says so instead of charging for the scan. bound_test.go proves it twice: reflect walks every In type and fails on a caller-sized field with no declared ceiling (the structural half — a new field cannot arrive unbounded), and every declared ceiling is refused over the wire with a refusal that does not carry the value back. A LITIGATION HOLD THAT ARRIVES MID-SWEEP KEEPS THE RECORD IN BOTH PLANES OR IN NEITHER. dispose sweeps the derived copy FIRST so nothing is orphaned in the warehouse, then deletes from the record re-asserting `hold = 0`. That protected the record and silently corrupted the copy: a record the delete declines to remove has already been swept, its seq is behind the delivery cursor, and deliver() asks the cursor rather than the world — so no retry re-sends it, pending() answers zero, and the row is present in the compliance record and permanently absent from the answer key a training join reads. A missing fraud label reads as an honest customer, and the row is the one somebody is litigating. remove() now reports what it kept, the sweep writes those back from the record, a repair that fails refuses the request rather than acknowledging a short copy, and `restored` is a NAMED state on the response. `disposed` counts what was disposed of rather than what was identified: a compliance report that says it deleted a record it is still holding is the wrong answer to the only question the report is asked. THE PUBLISHED PRECEDENCE RULE NAMES THE FIELD THE RESOLVER READS. The op exists so a caller holding a contested resolution can reproduce it, and its second term said `seen` while stronger() compares `knowable`. The two are equal for a live pipeline and differ for exactly the backfilled history the derivation exists to hold back, so a caller reproducing the rule got a different winner and no way to see why. The test counted the terms, which made the only property that matters unobservable; it now pins each term to the field at its position. Also: plugin/label/mcp.json is deleted. It is the only mcp.json in the tree, no app on main has one, the generator that wrote them was retired with its gate (manifest/mcp_test.go says so), and it declared seven tools under the old operation ids with nothing left to regenerate or compare it. 25 mutants in scripts/mutate.py, 11 of them new, 25 KILLED: each reintroduces one of these defects and the named test goes RED. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
0cf342ce78 |
merge main: the wove openapi.yaml for the merged surface
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
bce12beed1 |
merge main: dataset after risk, the brand header beside main's principal import, and main's higher floor
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
6986d025a7 |
merge main: the ground-truth plane addresses under the product it belongs to
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
ad15df5781 |
openapi: regenerate the subsets — the ai door renamed three resources and gave permissions back to IAM
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m13s
CI/CD / image (push) Successful in 18s
CI/CD / rollout (push) Failing after 11s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
plugin/ai/openapi.json was last written at |
||
|
|
19db4e6eb9 |
skills: the app is called skills, because that is what it serves
CI/CD / containment (push) Failing after 12m55s
CI/CD / gate (push) Successful in 20s
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The package, its directory, its plugin and its manifest row all said "agentskills" — a compound naming the AUDIENCE (agents) alongside the thing (skills). The thing is skills; every reader of this catalogue is an agent, so the qualifier distinguishes it from nothing. Renames the token only. The wire is untouched on purpose: the served addresses stay /.well-known/agent-skills/... and the document schema stays hanzo.agent-skills/v1, because those are an external discovery convention we implement rather than a name we own. The manifest row's Prefixes are unchanged, so nothing moves address — only the app's own name, its import path (apps/skills), its plugin binary and its file names change. Also fixes two comments still pointing at clients/agentskills, a path that stopped existing when the subsystems moved to apps/. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
3e368f4bc9 |
o11y: graft the surface, so its types are named o11y.* and the fleet weaves again
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 2m1s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
`make -f mk/fleet.mk openapi-weave` has refused since
|
||
|
|
20236d178d |
merge: main, which retired the committed MCP catalogues under this work
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m41s
CI/CD / image (push) Successful in 20m30s
CI/CD / rollout (push) Successful in 5m18s
CI/CD / reach (push) Failing after 35s
Hanzo CI/CD / cicd (push) Successful in 19s
main moved again while the suite ran. One conflict, modify/delete on plugin/gateway/mcp.json: main deleted all 116 committed tool files because the catalogue is a query now — the host asks each child over its ZAP socket rather than reading an array some earlier build wrote down — and this work had regenerated the gateway one. The delete wins, and it takes plugin/risk/mcp.json with it. That file is the same artifact class main just retired: nothing embeds it any more (plugin/embed.go), nothing writes it any more (mk/plugin.mk, describe.go), and leaving it would put back one of the 116 second sources whose whole defect was that they could only be stale or accidentally correct. Still nothing armed and nothing routed. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
3b50091c77 |
mcp: the catalogue is a query — delete the 116 committed tool files
The fleet's agent door answered from plugin/<app>/mcp.json: the tool array each
app's binary projected when it was BUILT, embedded by plugin/embed.go and handed
to zip as Plugin.Tools. 116 files, 49,865 lines, and a second source for a fact
every child already knows.
A second source can only be stale or accidentally correct. This one was stale in
the way no gate in this repository could see: o11y's 353 missing ops live in
github.com/hanzoai/o11y, so a go.mod bump in ANOTHER repo invalidated an artifact
in this one with nothing in the diff to say so. Regenerating it more often is not
the fix — a generator on a hook is still two sources with a race between them,
and the trigger is in a different repository. (
|
||
|
|
35f7ac4fcb |
merge: risk: the credential is the lane a stolen key travels (red-cleared, shadow)
The lifecycle defense: an edge traffic sensor, a per-credential abuse gate and the identity boundary's own attestation. Compiled in and mounted, SHADOW per org — edge.Store.Mode returns live only on an exact "live" match, is absent from the inherited base, and is never set here, so nothing is armed by landing it. /v1/risk stays unrouted; /v1/ml is untouched. Four conflicts, each resolved on its merits rather than by side: middleware_identity.go — an import collision where both sides were right. main added namespace.Sanitize (OrgHasUnsafeRune, the cross-org fold refusal), the branch added principal.Mint. Both symbols are live in the merged body, so both imports stay; either blanket resolution drops a defense and the build. middleware_ratelimit.go — the branch's code, main's reasoning. The branch tests the ROUTER's path (RoutePath + underPrefix) instead of the raw spelling, which is what stops /v1/billing/../v1/ai/chat from prefix-matching its way into a rate-limit exemption. Its rationale, though, describes an in-process HTTP self-dispatch that main has since replaced with a typed ZAP op, so the comment kept is main's, which is the one that is true here. plugin/o11y/main.go — complementary, not competing. The branch installs the identity boundary and the abuse gate in this hand-written main; main added cloud.ErrorHandler so a propagated refusal renders as its own status instead of 500. Those two middlewares are precisely what emits refusals, so the handler matters more after the branch lands, not less. Both kept. openapi/floor.json — the pointwise maximum, which is what Floor.Raise means: taking the lower side would silently undo the other's ratchet. Then regenerated from source, so the regeneration is the truth. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
abc239841f |
merge: risk: the strained state is a measurement, not a field (red-cleared, unrouted)
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
4b030b7716 |
auth: two key shapes, pk- and sk- — hk- is not a key
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m43s
CI/CD / image (push) Failing after 4m19s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
APIKeyPrefixes drops hk-, so a string with that prefix no longer reaches
isAPIKey and never travels to IAM's get-user door. It resolves to no
principal, exactly as any other unrecognized bearer does.
pk- is unchanged: it is still recognized, still short-circuited to nil in
validatedPrincipal, and still resolves through resolve-key to an org and
nothing else. Nothing here lets it authenticate.
The analytics free-text scrubber loses its hk alternative for the same
reason — the prefix names no credential, and the bearer/entropy/query-param
branches still cover every real one.
Fixtures that used an hk- value as a working key are re-cast to sk-;
enumerations of the family throughout the comments now read pk-/sk-. Those
enumerations also carried fw_ and hz_, which isAPIKey has not recognized
since they were dropped as never-minted entries, so they go too — a comment
that claims to describe isAPIKey should describe it.
plugin/dns/openapi.json is regenerated from apps/dns; openapi.yaml takes the
identical string. The remaining hk- in openapi.yaml and plugin/{iam,ai} is
projected from hanzoai/iam and hanzoai/ai and has to be ripped there.
|
||
|
|
beefb70009 |
reference: bound one request, bound one tenant's bytes, keep the version a decision cites
resolve could spend the whole process on one authenticated request. Two
amplifiers composed. A key had a count bound and no BYTE bound, and the domain
matcher split a host into labels and re-joined every tail, so an L-label host
allocated a copy of each of its L suffixes: one 8 KB dotted key materialised
16.8 MB and 100 of them 1.7 GB, measured over the router. And `sets` had no
bound and no dedupe, so naming one set N times ran N times the answers.
- maxKey bounds one key in bytes at every door it crosses — looked up,
written, removed — and REFUSES rather than truncating, because a shortened
key is a different key.
- a domain suffix is now a slice of the host, not a join of its labels: the
same answers in O(L) headers over one backing array.
- a call may name each published set once, and a set named twice is consulted
once.
The override write had the same hole from the other side: maxOverrides bounded
rows and nothing bounded a row, so 10,000 entries x 11 sets was gigabytes of
attacker-chosen bytes on the one volume every other organisation's store lives
on. The same maxKey closes it; an over-long note is refused rather than trimmed;
and what one organisation may occupy on that volume is now a figure the code
computes and a test pins, so raising rows, a key, a note or the catalog is an act
with its consequence next to it.
An override is a record, so it is now shipped before it is acknowledged, the way
apps/research and apps/books do it — this deployment is one replica with a
recreate rollout, and an unshipped write is a control an operator believes is in
force and is not.
prune spared ONE version: the call site passed the current version for both of
the statement's two placeholders, deleting the rows behind every citation taken
in the window before a refresh. What a take supersedes is now decided by
sweepOld over what the plane held before it, and proved against a warehouse
rather than against the text of the statement.
The publisher's end of the same amplifier is closed with the same door. A take
is refused whole if it carries a member longer than maxKey — one no lookup could
ever reach, so it is only weight in the warehouse, in every hydrate and in the
snapshot every request reads — or more members than a published set holds: swing
measures GROWTH against the version a take replaces, and a first take has nothing
to measure against, which after a cold start is every take. maxBody comes down to
six times the largest source in the catalog (measured: 2.6 MB), because the parse
allocates before any later gate can look at what it made. A publisher's redirect
must keep the two properties its origin already had, TLS and a destination
outside this network: this process runs in the cluster, where "wherever the
publisher says" reaches the pod network and the metadata address.
Also: a take whose size swings past 4x is refused and the previous version
stands (force is the operator lever); the disposable list is refused whole if it
names a mailbox provider, which is the one-row attack the size gate cannot see
on the one unpinned source; an attest receipt with no version or no designations
is recorded as a refusal instead of a current, fresh list; the plane sweeps at
cold start instead of refusing every set for six hours after a deploy; the one
cross-fleet aggregation states its own memory and time budget; every source
states a typed redistribution Basis from a closed vocabulary, on the wire, so
the licence position is an audit rather than a sentence; refresh reads the ONE
SuperAdmin predicate rather than restating it, and an admin of their own org is
refused there, because this route writes the baseline every org reads; and the
bridge sits on this app's own leaf, not on the /v1/ml parent two other products
answer under.
65 tests green under -race (one skipped: it dials the real publishers). Every fix
has a regression test that fails when that fix alone is reverted: 24 mutants, 24
killed.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
144be507b0 |
reference: the lookup data a decision consults, versioned and named
/v1/ml/reference publishes ten sets — disposable email domains, hosting and Tor address ranges, crawler user-agent patterns, delegated autonomous system numbers, card-scheme prefixes, browsers the fleet sees everywhere, and the freshness of the designation lists the screening engine holds. Six typed ops. The unit of version and freshness is the SOURCE, not the set, so one publisher's outage neither blocks the others' updates nor silently shrinks the set. A version IS the content digest of the sorted entries, which makes a re-take of an unchanged publisher a no-op that says so, and a half-landed version resumable from its cursor without depending on it — the primary key already deduplicates. Two planes, two stores. The baseline tables carry no tenant column, so a cross-tenant write is unrepresentable rather than refused; a tenant's own allow and deny entries live in that organisation's own store. Resolution is override then baseline, both through one candidate function. The baseline carries only published data under terms we hold — every source states its licence — and aggregates above a k-anonymity floor no single organisation can reach. Sources we may not redistribute are declared seams that refuse, because an absent set and an unlicensed one look identical from outside. A set that never loaded refuses rather than answering "not listed", and a stale set answers and says so: every answer carries the version, its as-of, its age and whether it is past the bound. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
dfeaec4eb8 |
risk: a caller is what the server attested, and a bound is a number of bytes
Three findings, one root cause: the sensor and the enforcement were keyed on the raw Authorization value, validated or not — a string the caller picks. A held verdict was walked out of five times in five by editing that header; the lane with no tenant could be filled on demand; and a caller could open one table entry per request. A CALLER IS A FACT WE STATED. Signal now carries two fingerprints of the same credential under different trust: Cred, set only when the identity boundary VALIDATED it, which is the only thing that can key a caller; and Presented, whatever the request carried, counted only as spread because a wall of invalid credentials from one address IS the stuffing signature. callerKey cannot see the second one. Both are built in ONE place (observation), pinned by a test. The scorer is asked about the same thing the sensor holds, so a refused caller cannot be re-judged as somebody else by editing a header. NOTHING IS REMOVED TO MAKE ROOM FOR SOMETHING ELSE. The reclaim pass that dropped the oldest half is deleted, not guarded: a table reclaims keys that are DEAD (unseen for a window, under no live verdict) and REFUSES what does not fit. So a flood cannot erase a neighbour's counts, cannot release a held verdict, and cannot overrun the ceiling — the previous rule skipped pinned keys and then admitted anyway, which is how 25,000 held callers lived in a table that published 20,000. THE BOUND IS IN BYTES. A cap on the NUMBER of keys is not a bound when the values behind them are not bounded, so every string that can enter an entry is clamped at the door, every entry has a published worst-case size, and every admission charges it against ONE process budget. Count x size IS the byte bound; a test fills a table with worst-case values, measures it, and fails if the published numbers understate it. The per-scope ceilings stay as the FAIRNESS bound, so the anonymous lane cannot take the room the tenants need. A BOUND THAT BINDS SAYS SO. Strain is graded — clear, full, refuse, blind — carried on the observation so an unmeasured caller is not screened as a brand new one, reported on the scope's own view, and announced once per rise rather than once per request. The lane that has no tenant is readable at last: it is named by the empty scope (?org=), which cannot collide with any tenant. WHAT THE SENSOR CANNOT SEE IT DOES NOT INVENT. A request with no validated credential and no client address has no identity; keying it under the empty address would file the whole internet in one row, read as the worst stuffing run ever recorded, and let one verdict refuse everybody. It is counted as traffic, named blind, and nothing is held against it. This is the live shape today: the balancer in front of the ingress is TCP with no PROXY protocol, so no client address reaches this process at all. THE LANE IS DERIVED FROM THE COUNTS, so it is computed inside the observation that produced them. Passing it in meant stating it before those counts existed, and every request ever counted landed in "unknown". ABSENCE IS NOT SILENCE. The fail policy turns on two facts, the same rule hanzoai/iam applies at its own gate: a privileged grant waits for a scorer that is THERE and did not answer, and proceeds when there is no scorer at all — or when every slot is held and nothing has come back for a stall, which is a deadlocked scorer, not a queue. Without that second fact one hung goroutine 403s every armed org's key store until the pod restarts. AN UNANSWERED SCREEN IS NOT A SALE. Only a scored verdict is billed; the count is split into answered and unanswered so a scorer that has gone dark is a number on the org's own report. A fact we do not have is ABSENT from the signals, never empty — an empty string is a value a scorer can group by. A SCOPE KEY NAMES THE ROUTE. canonicalService and the rate limiter's exemptions read the router's path, not the spelling: "/V1/AI/chat" reached the "/v1/ai/chat" handler while producing a different rate bucket and a different spend-cap axis. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
fa2c3f23cc |
label: ground truth, and the three properties it is worth nothing without
/v1/ml/labels is the answer key the model plane cannot build for itself: what turned out to be fraud, who said so, and when they could first have said it. Chargeoff, dispute, case, refund, review and the below-the-line sample all file into one append-only record per tenant — its own encrypted SQLite file, no org column, so a cross-tenant read is not forbidden but inexpressible — with a derived ClickHouse copy for joining at training scale. Seven typed zip ops, so the REST route, the OpenAPI operation, the MCP tool, the CLI command and every SDK method are projections of one declaration: mlLabel, mlLabels, mlResolveLabels, mlLabelCoverage, mlLabelVocabulary, mlDisposeLabels, mlHoldLabels. THREE PROPERTIES, EACH THE POINT OF THE PLANE, NONE OF WHICH THE FIRST CUT HAD. DURABILITY. Nothing in the package called OrgStore.Sync, so the only ship was CloseAll on a graceful shutdown. cloud deploys strategy Recreate at one replica: an ungraceful termination lost every acknowledged record since process start, and the successor hydrated the older durable snapshot OVER the local file — an acknowledged compliance record was not merely at risk, it was overwritten by an older copy of the tenant's own history. state.ship is now the ship-before-ack step every write path calls before it answers, and an unacked ship fails the request rather than acknowledging a divergent local copy. That covers BOTH shapes: a replica that never held the lease (ErrNotOwner) and one deposed between the write and the ship, whose fenced Put is refused at a stale round with no error at all. The two sibling durable planes hold the same contract (apps/research shipFor, apps/books shipLedger). DELIVERY. The cursor was the pair (wrote, id) over a write clock truncated to the second and a content digest — an order the writer never took. A write that commits after a concurrent delivery has read, whose digest sorts lower inside the same second, was already behind the mark: never mirrored, unreachable by any retry, and pending() answered zero because it asked the same predicate. A hole in the answer key reads as an honest customer. The cursor is now the store's own AUTOINCREMENT position, allocated inside the insert on the single connection every statement for a tenant runs on (sqlpool.Single), so cursor order is commit order by construction. LEAKAGE. `seen` is whatever the caller sent, bounded only by At <= Seen <= now+skew, and nothing tied it to any fact the server observed — a dispute filed today with seen == at was knowable a year before the record existed, and a backtest standing two days after the event resolved it. Fact.Knowable is derived server-side as the later of `seen` and the clock at the write, it is the only time visible() and stronger() read, and it is a column in the derived copy so the warehouse applies the same predicate the record plane does. A live pipeline is unaffected: Wrote is within minutes of Seen and the derivation changes nothing. Also: the coverage window now ends where maturity begins, so the gate on training no longer answers zero on its own defaults; Group returns the whole matured cohort so `matured` counts what matured and `unlabelled` says why `judged` is low; a litigation hold is a fact about the record with its own op that can also release one, instead of a digest-excluded flag silently dropped on any record that already existed; the warehouse partitions by month like every other table in the fleet rather than by tenant, whose cardinality is the customer count; and resolve answers one row per distinct event, so an event named twice is no longer its own conflict. Every one of those carries a mutant in scripts/mutate.py that reverts it and a test that goes RED when it does: 14 rows, 14 killed. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |