The Linker Doesn't Read Your Changelog: What libghostty 1.0 Actually Promises

Mitchell Hashimoto shipped libghostty 1.0 this week, and the headline feature is not a feature. It is a promise that nothing you already built against it will break.
That is the boring line in a release note, and it is precisely why the library got adopted. JetBrains now runs its IDE terminal on it — over 12.5 million active users by JetBrains' own count. Neovim has an approved pull request to replace libvterm with it. KDE is prototyping a Konsole replacement on top of it. Vercel's Turborepo renders task output with it, OrbStack embeds it, and there are community bindings for Rust, Go, Python, .NET, Swift and more. None of those teams chose libghostty because it emulates a terminal more beautifully than what they had. They chose it because they could afford to commit.
For context: libghostty is a C library that turns a stream of bytes into a screen grid. Ghostty the app is one consumer of it. Everyone else listed above is another.
Two Promises, and You Only Test One
Most teams have an instinct for API compatibility: if I upgrade your library, my code still compiles. That promise is easy to keep because your compiler enforces it for free. If you break it, the build turns red before anyone ships anything.
ABI compatibility is the other promise. ABI stands for application binary interface — the unwritten conventions two already-compiled pieces of software use to talk to each other: where arguments go in registers, how many bytes a struct occupies, what integer an enum member maps to. If the API is what you write, the ABI is what the compiler baked into the binary the moment you looked away.
Here is the break that catches almost everyone. You have a struct that callers allocate themselves. You add a field. That is an additive, source-compatible change in your changelog. It is also a binary break: a program compiled against the old header allocates the old, smaller size, and your new library writes into memory that program never reserved. Recompiling fixes it. Nobody recompiles. That program was installed by a distribution eighteen months ago and it is still on disk right now.
You may not think you ship an ABI. You probably do. If anything is compiled, cached, pinned, vendored, linked, or baked into an image against your code, you have a binary contract with it. That includes your SDK, your plugin host, your base container image (entrypoint, env vars, volume paths), and the wire format your services exchange when only one side gets deployed.
Three Moves Worth Stealing
libghostty was designed for this from the start. Three of its decisions translate to any platform work.
1. Opaque handles and a keyed accessor
Callers never see the layout of a libghostty object. They hold a handle and read or write values through a small set of getters and setters keyed by an enum. Internally, everything can move. Externally, nothing does.
The payoff is subtle and large: adding a new readable value means adding an enum key, not a new exported symbol. The binary surface stays the same size while the feature set grows. The cost is real too — you pay an indirect call and you give up direct field access. That is the trade: a little speed and ergonomics for permanent room to grow.
2. Structs that carry their own size
When you genuinely must expose a struct, put its size in the first field and reserve padding afterward. The receiving side can then ask 'how big is the thing you gave me?' and handle an older or newer version deliberately rather than optimistically.
3. Unknown input returns a value, not a crash
Programs compiled with newer headers, linked against an older library, get a defined unsupported result instead of undefined behavior. Enums are pinned to int on every compiler, as far as the C standard allows. Every edge behaves like a documented answer instead of an open question.
The Part Everyone Skips: Enforce It With a Diff
An ABI policy in a wiki is a wish. libghostty runs every change through libabigail against a committed baseline on Linux, plus golden type manifests on macOS and WebAssembly. On ELF platforms every exported symbol is versioned, so the dynamic loader refuses to load a mismatched library by name rather than letting it fail at 3am in a function nobody wrote.
That distinction matters more than it sounds. A refused load is an incident with a stack trace and a filename. A mismatched ABI that loads anyway is a wrong number in a report three weeks later.
The transferable rule: if no test fails when you break your contract, you do not have a contract. You have a habit.
The Consumer's Half of the Contract
Here is the piece almost every team misses, and it is the one you can act on this week.
Forward compatibility is a property of the reader, not the writer. The library can promise to return an unknown value gracefully. It cannot promise that your switch statement has a default branch. If you consume an enum from outside your codebase and every case is enumerated with no fallback, the first new value that arrives is your outage.
The fix is cheap:
- Every enum you receive from outside your boundary gets a default branch that means 'I do not know this yet.' Say it out loud in the comment, so the next developer does not delete it as dead code.
- Write a test that feeds in a value that does not exist yet — value 99, a status string from next year's release. If your test suite cannot represent a value you have not shipped, you are not compatible. You are lucky.
- Log the unknown. A default branch that silently swallows new values turns a compatibility win into a debugging mystery.
What It Costs, Honestly
Designing for the linker is not free, and anyone who tells you otherwise is selling a framework.
You can only add. You can never remove. Not a function, not a field, not a spelling mistake you made in 2023. Semantic changes are off the table too — same signature, new meaning is the worst kind of break because it compiles and tests clean if your tests were written against the old meaning.
You will write more boilerplate. Dispatch tables, key enums, version checks, structs that carry their own size. Code that a reviewer will call over-engineered until the first external consumer shows up.
You will ship slower. Design review before merge, deprecation windows measured in years, a genuinely painful first release of any new API because you know you are stuck with it.
So when is it not worth it? An internal library in a monorepo where every consumer deploys from the same commit. A pre-1.0 anything. A tool with exactly one consumer that you can rebuild together. In those cases, ship fast, break things, and — this is the important part — say the version number out loud and do not promise stability you will not enforce. A 0.x with a reputation for churn costs you less than a 1.x you quietly break.
The middle path that most teams should take: freeze the surface at 1.0, allow additions only, and pay the migration cost in one deliberate 2.0 with a written guide, rather than bleeding a small break into every minor release.
Packaging Is Part of the Contract Too
libghostty 1.0 also ships the unglamorous machinery that decides whether a downstream packager can adopt you at all: a signed source tarball plus a companion dependency tarball in the build tool's own cache layout, so a distribution can build completely offline. A proper SONAME, pkg-config files, a CMake package config, and respect for --prefix, DESTDIR and lib64. Debug info kept by default so distributions can split it out themselves. A machine-readable dependency manifest with version, SPDX license and hash for every dependency, and no copyleft anywhere in the tree.
Each of those is a decision that says: whoever packages us is a person with a build system we have never seen, and we are going to make their day easy. The alternative — and you have met this library — is the one whose build reaches out to the network at compile time and whose license file is a paragraph in a wiki.
There is a security argument here too, and it is not a small one. An offline, reproducible, hash-verified build path is a supply-chain control. It is the difference between 'we can audit this' and 'we hope the CDN still serves that tarball.'
A Three-Question Test Before You Merge
You do not need to write C to use this. Before you change anything other people build on, answer these:
- Old binary, new library. If a program compiled against last quarter's headers links against today's build, what happens — a crash, a wrong answer, or a defined error I chose on purpose?
- New binary, old library. Same question in reverse. This is the direction people forget, and it is the one that breaks rolling deployments where the two sides of a service update minutes apart.
- Which test fails? If the honest answer is 'none,' you are relying on review discipline. Review discipline is a person. People take vacations.
For engineering leaders evaluating a vendor SDK or an internal platform library, that third question is a better buying signal than the feature list. Ask 'where is your compatibility policy enforced?' and watch what happens. The teams that have an answer know exactly what they have promised you. The teams that start explaining their changelog process have not thought about it yet.
The uncomfortable version for your own org: the internal library with forty consumers across four time zones is the place this matters most, and it is almost always the place with no policy at all — because nobody owns a contract that was never written down.
Key Takeaways
- API compatibility is checked by your compiler. ABI compatibility is checked by your users, at runtime, usually on a Friday. Adding a field to a struct that callers allocate is source-compatible and binary-breaking.
- Opaque handles plus enum-keyed getters let you add capabilities without adding exported symbols. You trade a little speed and ergonomics for permanent room to grow.
- Return a defined 'unsupported' value for anything you do not recognize. Undefined behavior on an unknown input is a design choice, not a law of nature.
- A policy without a CI diff is a habit. Compare every change against a committed baseline, and version your symbols so the loader refuses a mismatch instead of discovering it later.
- Forward compatibility lives in the consumer. Give every external enum a default branch, log the unknown, and write a test using a value you have not shipped yet.
- Stability is expensive and optional. Promise it only where rebuilding everything at once is impossible — and when you do promise it, make packaging as easy as the API.