Files
zeekay ad5a010f79 Three words for what a proposal is, and a generator that refuses
The same fact lived in nine hand-kept places and they disagreed about 58
proposals. This makes the file the only authority and everything else a
projection of it.

ONE VOCABULARY, in vocabulary.json, read by the lint, the generator and the
doc site. Three words, each answering "can I rely on this?" and nothing else:

    Draft    nothing implements it yet, or its own text says the work is ahead
    Final    the thing it specifies exists in the code
    Living   governance and process: amended in place, never finished

Eight spellings folded in. `Active` was the one doing real damage: on four Meta
and Process HIPs it meant living, on 48 others it meant shipped, and one word
doing two jobs is why nothing agreed. `Review` and `Last Call` described a
queue rather than a document, and a proposal under review is still one you
cannot build against.

STATUS IS EARNED, not declared. Every Standards Track proposal was checked
against the estate: 235 have their implementation, and eight do not and say so.
HIP-0095 carries a twelve-week plan ending at "mainnet launch" and has no code.
HIP-0525 specifies an all-machine meeting while apps/meet refuses a machine a
seat. HIP-0078 names a prover at a "planned location" that does not exist.
HIP-0114 is the cog.* procedure set, and that procedure set returns nothing.
HIP-0003, HIP-0102, HIP-0305 and HIP-0901 likewise: looked, found nothing.

SIXTEEN TOMBSTONES DELETED rather than statused. A dead document kept alive in
the index is one concept wearing two names. Each successor exists and carries
the text, and the 90 references that pointed at a tombstone now point at it --
whole markdown links at once, so link text cannot outlive its target. Three of
the sixteen were pure compatibility: HIP-0028 specified a migration off a
Bitnami chart, HIP-0029 dual-engine MySQL, HIP-0030 a schema-deprecation
policy. We ship no backwards compatibility, so we do not specify it.

Nine links were already broken and had been since the corpus was renumbered:
short-form ./hip-5.md paths that never existed. Two named the wrong proposal --
one cited "HIP-1: Hanzo Multimodal Models" where multimodal is HIP-0003.

THE GENERATOR REFUSES more often than it writes, because a short table looks
exactly as authoritative as a complete one. Rows must equal files; a shrinking
index must say by how much; a section whose input is missing is an error, not
an omission; nothing is written until all of it validates. Its self-test breaks
the corpus eleven ways and requires each to refuse AND leave README byte-
identical -- a guard that reports after half-writing has protected nothing.
The lint's second implementation of the same comparison is gone.

The site rendered 52 proposals with a fallback grey badge and counted them
toward none of its three stat tiles, because docs/lib/source.ts declared a
status union by hand: it admitted a `Stagnant` no file has ever carried and
omitted the one 52 files did. Five components each kept their own copy of the
colours. All six now read vocabulary.json.
2026-08-28 18:05:41 -07:00

9.1 KiB

HIPs

Hanzo Improvement Proposals: the specs in HIPs/, indexed by README.md, and the site at https://hips.hanzo.ai built from docs/.

How this ships

One way, and it runs on our own stack:

push  ->  github.com/hanzoai/HIPs        (development lands here)
  ->  git.hanzo.ai/hanzoai/hips           CANONICAL
          .hanzo/workflows/sync-from-github.yml  PULLS main, every 10 min
          .hanzo/workflows/ci.yml                checks the index
          .hanzo/workflows/deploy.yml            builds + publishes the site
  ->  api.hanzo.ai/v1/projects/hips/deploy  the Sites plane
  ->  hips.hanzo.ai

git.hanzo.ai is canonical; GitHub is where people push. Every check and deploy is a workflow under .hanzo/workflows/, which the forge reads. .hanzo/workflows uses GitHub Actions syntax, so a workflow moves between the two by changing directory and nothing else.

The forge takes the FIRST workflow directory that exists, so .hanzo/workflows also shadows .github/workflows there — mirrored GitHub workflows cannot execute on the forge by accident.

The sync is a PULL, and that direction is load-bearing. It used to be a push from .github/workflows/sync.yml; 3047c73 deleted that on the belief the Hanzo GitHub App webhook had superseded it. No such webhook exists on this repo, so the forge froze at 9f792bf while GitHub moved on 15 commits, deploy.yml never reached a runner, and the site stopped publishing for a day without failing anything. A pull cannot fail that way: it needs no credential (this repo is public), no webhook, and no GitHub runner — and hanzoai currently has zero online GitHub runners, which is why the old push job queued for hours and never ran. Fast-forward only; a divergence fails loudly rather than force-pushing.

No GitHub Pages and no Cloudflare Pages. The site is static files the Sites plane serves — no image, no CR, no pods, because nothing here executes.

What checks what

One lint, two callers. scripts/lint-hips.py is the only implementation of the rules, and both a contributor and CI run the same file:

python3 scripts/lint-hips.py          # exits 1 on any ERROR
python3 scripts/lint-hips.py --list   # the checks, by code
python3 scripts/test-lint-hips.py     # proves the lint FAILS when it should
python3 scripts/index.py              # project HIPs/ onto README
python3 scripts/index.py --check      # exits 1 if README has drifted
python3 scripts/test-index.py         # proves the generator REFUSES

.hanzo/workflows/ci.yml used to carry its own copy of the checks as an inline heredoc. That meant the gate existed only on the forge and a failure could not be reproduced without pushing. It now calls the script, and calls the self-test first.

The self-test copies the corpus, breaks it 20 ways — one per check — and requires the matching error code each time. It also asserts that every check the lint declares has a test, so adding a check without one fails. This matters here more than most places: the GitHub workflow this lineage replaced printed every missing field and then exited 0, so a HIP with no status shipped green. A check that has only been seen to pass has not been shown to work.

Checks are FM* front matter, ST* structure, LK* links, IX* numbering, PL* policy. Each fired on a real defect when it was written: 101 H1 headings disagreeing with their own front matter, 16 files with no H1 at all, 9 files using a status word outside the vocabulary, a requires: pointing at a HIP that never existed, 5 duplicate headings, and 4 subsections numbered outside their parent section.

Two policy checks are worth knowing about because both were wrong on the first attempt and had to be narrowed:

  • PL001 fires only when a comparative phrase sits within 80 characters of a named third-party product. Firing on the phrase alone reported 15 lines of which 12 were ordinary English ("only as good as the source behind it"). It deliberately does NOT fire on weighing two candidate upstream dependencies against each other — that is engineering rationale, not a claim about us.
  • PL002 fires on a specific private-org repository path, not the bare org name. HIP-0135 is exempt: it is the document that draws the line, so it must be able to name both sides.

One index, generated

HIPs/*.md front matter is the only authority for what a HIP is. The site reads ../HIPs directly; README.md's two tables are projected by scripts/index.py.

The generator refuses far more often than it writes, because a short table looks as authoritative as a complete one. Nothing is written until everything validates: rows must equal files exactly, a table that shrinks must say by how many (--delete N), a section that cannot be rebuilt is an error rather than an omission, and every requires: must resolve. scripts/test-index.py breaks the corpus eleven ways and requires each to both refuse AND leave README.md byte-identical — the second half being the one that matters, since a guard that reports after half-writing has protected nothing.

scripts/index.py --check regenerates in memory and compares, which is how CI holds README to the files. The lint used to carry a second implementation of that comparison (IX001, IX002, IX004: row-by-row title, type, category and status); it is gone. The generator is the one statement of what the projection is.

The vocabulary lives in vocabulary.json, which the lint, the generator and the doc site all read. It used to be written by hand in nine places — including a TypeScript union in docs/lib/source.ts that admitted a Stagnant no proposal has ever carried and omitted the one 52 proposals did, so every one of those rendered with the fallback badge and counted toward none of the three stat tiles on the home page.

Four committed hip-index.json files used to exist alongside it. They are gone. docs-old/ — 16 tracked files including its own index — described 25 HIPs while 138 existed, and claimed four (16, 21, 22, 23) that do not exist at all; nothing referenced it. docs/hip-index.json and docs/site/hip-index.json were generated-and-committed, already 3 behind, and read only by docs/site/index.html, which is not in the deployed export. Do not reintroduce a committed index. If one is needed, generate it during the build.

Deploying the site

deploy.yml builds the static export and publishes it to the Sites plane. There is no image, no CR and no operator step: this is a Next.js export, ~170 MB of files that never execute, and baking them into a container so a Go binary can serve /public buys nothing.

pnpm build -> docs/out
  -> POST /v1/projects/hips/deploy {"source":"git"}   202 + an upload grant
  -> POST each file under the grant                   bytes skip the API
  -> POST .../deployments/<id>/complete {status,keys}

This repo holds no S3 credential. It used to need the hanzo-sites bucket keys — one shared bucket separated only by key prefix, so those keys were write access to every org's site. The 202 now carries a presigned POST policy confined to this site's prefix and expiring in 30 minutes. Deletion moved with it: CI reports its manifest as keys on completion and cloud prunes the prefix, because a write-only grant cannot delete.

The one secret is HANZO_DEPLOY_TOKEN, which identifies us rather than granting storage. It is set on the forge, since .hanzo/workflows/ is what the forge reads; GitHub's secret store is not in this path at all.

Writing a HIP

Copy docs/templates/hip-template.md to HIPs/hip-<NNNN>-<slug>.md, then run python3 scripts/index.py and python3 scripts/lint-hips.py.

It lands as Draft. It becomes Final when the thing it specifies exists in the code — that is the only thing that moves a status. Meta and Process HIPs are Living: they are amended in place and never finish.

The template exists now. It did not until 2026-08-04, although this file had told every author to copy it for months — which is the simplest explanation for why 138 HIPs drifted into four H1 styles, eight status words and three different spellings of "Security Considerations".

One thing we build → one public repo → one HIP. A Standards Track HIP describes something that exists; type: Process is for how we work and is not expected to map to a repository.

Measured on 2026-08-04, the corpus is a long way from that rule and it is worth knowing before adding to it:

  • 96 of 121 public hanzoai repositories are named by no HIP.
  • 69 of 123 Standards Track HIPs name no hanzoai repository at all.
  • 24 Standards Track HIPs specify only PRIVATE repositories — including HIP-0106 (cloud), HIP-0027 (kms), HIP-0068 (ingress) and the whole CRD family (operator). By HIP-0135's own rule a private repository in hanzoai is mislabelled or in the wrong org, so the fix is to publish, not to withdraw the HIP.
  • Fragmentation: 19 HIPs describe operator, 7 describe cloud.

Before writing a new HIP, check whether the thing already has one. HIP-0041 was rewritten rather than duplicated for exactly this reason: a brief asserted no CLI HIP existed, and HIP-0041 had specified the CLI since February.