Related to my last post, Allen Pike covers similar ground.
Guidelines, Guardrails, and Git
Permalink → — and so do others
It's incredible what you can accomplish when you don't care who gets the credit
Related to my last post, Allen Pike covers similar ground.
Permalink → — and so do others
In her piece Test Coverage Won’t Save You, Jenn Cooper argues that coding agents spread incoherence across a codebase faster. One line stood out to me, because it’s proven true in my own projects:
Every merged PR becomes a precedent.
The natural outcome is a feedback loop. An agent copies whatever patterns it finds, and it can create and spread bad patterns all on its own.
I’ve been building a tool called repopo that enforces repo policies: the conventions you’d otherwise explain in code review, PR after PR. I think it could also be useful for exploring the just-in-time agent coaching Jenn talks about: telling an agent what to fix, and what not to break, at the moment it’s working.
Repopo came out of my work on the Fluid Framework. We had a lot of rules we needed to enforce across the repo that didn’t belong to any particular linter. ESLint is great for code, but it’s not great for questions like, “Is this project set up properly?” “Does this package have a README?” “Does the README have the right sections?” Those rules are about the repo itself.
So we built a policy check tool into our build tooling and made it flexible. It runs against files in the repo that match a regex, and it can fix many problems automatically. It worked well enough in Fluid that I wanted the same thing everywhere, but I didn’t want to bring Fluid’s build tools everywhere.
The result is repopo: a lint tool for any file in your Git repo, with a straightforward way to write your own policies. It aims to be fast enough to run in your inner dev loop.
Jenn recommends fitness functions: checks that protect properties of a system’s architecture rather than its behavior. Her examples include forbidding certain service calls and capping bundle growth. She also stresses what you tell an agent after a check fails — how to fix the violation and what it shouldn’t break.
Now, you can write architectural checks in a test runner. Nothing stops a test from asserting import boundaries. But a passing test suite can’t tell you about something you never took the time to write a test for, and repo-wide tests take plumbing that most people won’t bother to build.
Repopo makes those checks cheaper to write. Instead of writing a test that loads every file in the repo and checks each one, you define policies and repopo handles the common plumbing. You can write your own policies or use the bundled ones. A file policy matches paths with a regular expression and runs a handler for each matching file. The handler can return an error with instructions for a manual fix, or, if the change is mechanical, it can fix it.
I use a bunch of the built-in policies for package metadata and scripts in my tools monorepo, but a repo-specific rule shows the connection to Jenn’s argument better.
In my monorepo, each package’s package.json scripts hold the implementation commands, and nx.json holds shared Nx
orchestration. Package-specific target settings go in package.json under nx.targets. But Nx also supports
package-level project.json files, so a contributor — human or otherwise — could add one, configure a task, and pass
every test.
Sounds harmless, right? Except now there are two places to configure tasks, and the next person doesn’t know which one
is right. With luck they’d follow the most common pattern, but they might follow the most recent one instead, and that
sends them down the project.json path again. That’s the feedback loop I talked about earlier.
So I banned it. In this repo, we don’t use project.json:
const NoPackageProjectJson = { name: "NoPackageProjectJson", description: "Keeps Nx task orchestration centralized instead of adding package-level project.json files.", match: /^packages\/.*\/project\.json$/, handler: async () => [ { message: "Package-level project.json files split task configuration across competing locations.", manualFix: [ "Put implementation commands in the package's package.json scripts.", "Put shared orchestration and task defaults in root nx.json.", "Use package.json nx.targets for package-specific target settings.", "Follow packages/repopo/package.json and nx.json as examples.", "Preserve required tasks and caching settings when migrating; do not delete functionality to pass this check.", ].join("\n"), }, ],} satisfies FilePolicyDefinition;There’s no auto-fix here. If repopo finds one of these files, something went wrong, and a person (or an agent) needs to fix it.
You could also add a rule to your AGENTS.md that says, “Don’t add project.json; do it this way instead.” The problem
is that agents, just like humans, don’t always follow the rules. You still need something deterministic to enforce
them. Ideally your agents rarely break the rule, just like your humans rarely break it — but when they do, something
reliable catches it.
The manual fix steps describe what’s wrong and what’s right at the moment the contributor is paying attention, because they just made the change and the check runs in their inner loop.
And don’t skip that last manual fix step: “Preserve required tasks and caching settings when migrating; do not delete
functionality to pass this check.” Deleting the new project.json without moving its behavior would satisfy the path
check and break the change. Jenn calls out this failure mode directly: an agent will optimize for a green check at the
expense of the feature you asked for.
Some rules need more context than a single file. For those, repopo has repository policies, which run once after the file policies finish.
The built-in SharedDependencyVersions policy is a good example — it checks that members of a Cargo workspace agree on
dependency versions. It’s a Rust-specific policy, but the same API is there for your own cross-file rules.
Could you use them for Jenn’s example of tool handlers calling AI services? Sure, but it’s harder than it sounds. Do you care about direct imports only, or calls through intermediate modules? What about import aliases? Dynamic imports? A text search for one import string will leave gaps. If your linter or a dependency-analysis tool already handles the boundary you need, use that. Repopo is useful for checks that don’t have a better home, but it’s not a general-purpose replacement for existing static analysis tools.
Jenn also describes ratchets for measurable things like bundle size: set a ceiling at the current value, then fail the check when a change increases it by too much.
I wouldn’t use repopo for bundle size. You can’t measure a bundle until you build it, and a full build is too slow for a check you run in your inner loop. Bundle size checks should run in CI at minimum, and a dedicated bundle-size tool will do the job better than anything you write yourself. Those tools already handle compression, per-entry-point budgets, and reporting the change on each PR.
Ratchets on source files are a different story. Repopo already walks the files in your repo, so a custom policy can count lines or find files that still use a deprecated pattern without waiting for a build. Have the policy compare the result against a limit in committed config and report both numbers when it fails.
Pick the number with care, though. A file-length limit sounds reasonable until an agent splits one understandable module into four awkward ones to get under it. Put that in the manual fix, too: a split has to leave each module easier to read.
There’s a lot repopo leaves up to you:
That last one is where the agent-based reviews Jenn describes come in, including reviews that flag tests preserving patterns the team wants to retire. I’d run those alongside deterministic checks. When a reviewer keeps flagging the same problem, ask whether the rule is precise enough to become a policy. If it is, write the policy and stop leaving the same review comment.
To try it, install repopo globally or, if you’re working on a TypeScript/JavaScript project, into your project as a dependency.
Without a config file, repopo runs a small default set of policies. To use your own, create repopo.config.ts in the
root of your repo and register them with makePolicy:
import { type FilePolicyDefinition, makePolicy, type RepopoConfig } from "repopo";
const NoPackageProjectJson = { // The policy from earlier in this post} satisfies FilePolicyDefinition;
const config: RepopoConfig = { policies: [makePolicy(NoPackageProjectJson)],};
export default config;Then run:
repopo check # report violationsrepopo check --fix # fix what can be fixed automaticallyIf you installed repopo as a project dependency, prefix those commands with npx.
I’d put the read-only check in CI and ask agents to run it before they declare a task done. The docs cover the rest, including partial checks.
If you’ve got a convention you’re tired of re-explaining in code review, give repopo a try. If you write a policy other people could use, send a PR, or export it from your own package like I do with sort-tsconfig.
Permalink → — and so do others
Beryl is my library for type-safe channels over Web Sockets. I eventually want to publish it to Hex so that others can find and use it with as little friction as possible. Beryl as a whole targets the Erlang runtime, because it has hard dependencies on BEAM infrastructure and is primarily a server-side library.
However, there is a piece of beryl that can target both JavaScript and Erlang — the state machine of the core protocol. I wanted to break that piece out so that I could find out whether a beryl-like runtime could run on Cloudflare Durable Objects. Spoiler alert: it can, but more on that in a future post.
I broke out the core state machine into its own package, beryl_protocol, which you can think of as the pure rules for
a connection. It tracks things like: Has this client joined this topic? Is a join still waiting for approval? Can this
reply still be sent? Has the connection timed out?
The rest of beryl does the actual work: it calls your code, sends data, and manages processes. All of that code remains
in the beryl package, which is tightly coupled to the BEAM.
This separation raised a question: what should I do with the beryl_protocol package when it comes time to publish to
Hex? Right now, with Git-only releases, consumers can just use beryl directly
without knowing that beryl_protocol exists. It is an implementation detail of how the code is structured, not
something users should think about.
I could publish beryl_protocol as a separate library. Maybe someone else would use it! (Hubris!) But I don’t want to
create a separate release just because I found a useful separation within my project. Packages such as beryl_mist and
beryl_ewe give users a choice of transport, which makes separate releases useful. There’s no compelling reason to
offer the protocol package on its own, especially not just to satisfy a Durable Objects experiment, which can use a Git
dependency instead.
I wanted to keep beryl_protocol as a separate package in the repository so I could build and test it on both Erlang
and JavaScript. For Hex, though, I wanted to ship its source inside beryl.
Since I already use trellis to manage releases, I added source bundling in trellis 1.1. It’s specifically
designed around beryl’s needs, but I think it will suit other similar cases where the package boundary makes sense
inside a project but not outside it. My root gleam.toml includes:
[tools.trellis.publish.bundle]beryl = ["beryl_protocol"]When I publish beryl, trellis creates a temporary package with both source trees. It preserves the module names and
imports, removes the separate beryl_protocol dependency, and includes its external runtime requirements. It then
validates and publishes the combined package without changing my checkout. Conflicts, such as duplicate module names or
differing dependency requirements, stop publication. Those checks only cover the package I’m publishing. A consumer who
installs beryl alongside a separate copy of beryl_protocol can still hit a module collision.
Consumers will install beryl without a separate protocol release, and I keep a package boundary that holds the
protocol honest about JavaScript support.
The trellis source bundling docs cover the details, and the beryl workspace configuration is a working example.
Permalink → — and so do others
I’ve been working on a project for the past few months called beryl. It attempts to bring a real-time channels abstraction (rooms and topics, for those familiar with the space) to Gleam. A channel is a named group of connected clients that exchange real-time messages. My goal is to make it easier to build real-time applications, web applications specifically, in Gleam.
I’m fairly new to Gleam, and this is the first big project I’ve built with it.
I’m also using LLMs a lot. Now you know.
When I started this project, I used Phoenix Channels as design inspiration. Phoenix Channels is written in Elixir. It’s in the BEAM ecosystem, but it’s built in the Elixir language, and therefore has some different programming models and styles that make sense in the Elixir world but don’t necessarily make sense in Gleam.
I don’t know Elixir, so I borrowed Phoenix’s design rather than its code, but in the end it doesn’t really matter, because a lot of the code stems from the design.
I talked about the overall design and experience of that initial version here:
I liked the Gleam API, but it had a type-safety problem. To keep sockets with different message types in one registry, beryl v0.0 erased those types at the registry boundary. Code on the other side had to recover information the compiler could no longer verify. If you’re curious, you can still use beryl v0.0. I did some clever (to me, anyway) things to make sure that the type erasure was as safe as possible, but it still felt like a compromise compared to a fully type-safe approach.
The clearest feedback from the Gleam Discord was that the heterogeneous registry worked against the language. I had copied a Phoenix design, then used type erasure to make it fit. The result felt more like Elixir than Gleam.
They were polite and convincing, so I went back to the drawing board.
I took a harder look at Lustre, which implements the Elm Architecture. Further research revealed that Gleam’s OTP actor model uses a very similar state-transition design.
Could the same state-transition design work for socket state?
What if we took the Elm Architecture of state transitions and applied it to socket state? That’s the question the new design is built around, and it turns out to work pretty well. Lustre and OTP actors already follow the same four steps: create some state, define the typed messages that can arrive, handle one message against the current state, return the next state plus whatever should happen next. Only that last step differs. Lustre renders a view. An actor continues or stops.
beryl returns effects that turn into frames over the wire.
| Role | Lustre | OTP actor | beryl |
|---|---|---|---|
| state | model | actor state | one model per socket |
| input | Message | mailbox message | socket.Input(Message) |
| transition | update | on_message | update |
| next step | #(model, effect) | actor.Next | socket.Next(model, effects) |
In raw dispatch, the core beryl API, the loop looks like this:
import beryl/socketimport gleam/option.{None}
type Model { Model(joined_topics: Int)}
fn init(_info: socket.ConnectInfo(Nil)) -> #(Model, List(socket.Effect)) { #(Model(joined_topics: 0), [])}
fn update(model: Model, input: socket.Input(Nil)) -> socket.Next(Model) { case input { socket.Join(_topic, _payload, ref) -> socket.Next(Model(joined_topics: model.joined_topics + 1), [ socket.AcceptJoin(ref, None), ]) // ...more input handling here... _ -> socket.Next(model, []) }}There’s no view, which changes how you think about output. Changing the model doesn’t send anything over the wire. A
frame goes out only when an effect asks for one — accept this join, reply to this client, broadcast to this topic,
update presence, close a topic. Output is an explicit, ordered list, so the model is free to hold things that never
leave the server.
The other difference from a frontend is that init runs once per connected socket, not once per application. Every
socket is its own actor with its own model. Shared state — the poll totals everyone in a room needs to agree on —
lives in an actor your application owns, and each socket talks to it. Per-socket state and shared domain state stay
apart.
Play with the poll below to see what happens when three users vote and disconnect. Most of the beryl state is ephemeral, but what’s in your storage actor outlives the sockets.
Both layers use the same loop. Which one you use depends on how much routing you want to handle yourself.
Raw dispatch is the core. You give beryl an init and an update, and every event on a socket arrives at your
update as a single socket.Input value: joins, client messages, binary frames, close events, and your own typed
server messages. You do the routing for all of it. You return socket.Next(model, effects) or socket.Stop(reason).
It’s the clearest place to see the whole loop, which is why the beryl tutorial starts there.
Channels sit on top and do the routing for you. A channel is a topic pattern plus a typed join callback that either rejects the join or accepts it with private state and a few callbacks — on_message, on_info, on_terminate. You register a list of handlers, patterns match in registration order, and each joined topic gets its own instance and its own state, pruned automatically when the topic closes.
The part I’m happiest with is that channel state is typed all the way through. Phoenix keeps per-channel state in socket.assigns, a map of atoms to untyped terms. A beryl channel keeps a value of its own type, known to the compiler, and unrelated channels can still live in the same handler list. Nothing gets stuffed through Dynamic. The same is true of server-side messages: notify delivers your own type to the right join’s on_info, and a sender only ever addresses the join that produced it.
Channels return actions instead of effects, but they work the same way effects do, with one useful constraint — an action can only touch its own channel’s topic. Cross-topic work has to go through an explicit API. That felt restrictive when I wrote it, but after more thought, I came around to it.
If you have more than one topic namespace on a socket, the channel layer is what you want. If you want one topic family and total control, raw dispatch is right there underneath, and moving between them doesn’t change the wire protocol.
I’ve skipped a lot here. The tutorial on the docs site builds a live poll end to end — the raw loop first, then the same poll moved onto channels, then typed server messages, presence, and supervision. That’s the deep dive for anything I described that you want to see in more detail.
The new API replaces the heterogeneous registry with typed channel state and explicit effects. More to the point, it feels a lot more like Gleam than what I had before, which was the whole idea.
Permalink → — and so do others
One challenge with modern software distribution, especially for libraries, is that you need to get feedback on an API before stabilizing it, but some publishing platforms reasonably discourage publishing pre-1.0 packages, because such packages cannot adhere strictly to Semantic Versioning.
But you still need to distribute such software in some way if you want to get feedback. In Gleam, the answer is to ask users to take a dependency on your source in Git. In Gleam 1.18, this received a major upgrade with support for paths within a Git repository, making Git dependencies on monorepos a real thing. I rely on it heavily.
But just because the pre-1.0 world is technically the Wild West doesn’t mean we can’t get in the habit of following a release process prior to 1.0. However, because “doing a release” is so often synonymous with “publishing to a registry” oftentimes the first time you’re exercising the release machinery is during a 1.0. Not fun!
With my Gleam monorepo tool, trellis, I added support for a Git-only release style, which allows you to use the same change tracking and release mechanism you use to eventually publish your work to a registry without actually doing that. But you can still do every other part, including using change tracking to determine release types, tagging each package individually in a monorepo, and create a rolling tag that moves as minor or patch versions are released.
The rolling tags are the flagship feature, because they give your users something closer to a version range like they would have on a registry. They can depend on a moving series tag like v0, and get updates automatically every time they re-resolve.
That is useful, but it creates a second problem: a moving ref is not a pin. It is great when you deliberately want to pick up whatever the latest compatible release is supposed to be, but less so when a routine dependency operation moves you from one commit to another before you have looked at what changed.
So the workflow needs two refs, not one. The series tag is the update target. The commit is the thing you actually trust. When you want updates, you resolve against a tag like v0, inspect what you got, and then run trellis pin to rewrite the moving ref to the exact commit you accepted.
That is the scenario trellis pin is built for. Library authors can publish Git-only releases with useful series tags. Library users can start from those tags without leaving a floating dependency in their project. If you’ve used ratchet or similar tools to pin GitHub actions, then this will be very familiar.
With repository series tags and trellis pin, you can support users taking Git deps on your projects while still giving them a clean path to intentional updates for security fixes, new features, etc.
And worry not — you can use trellis pin on its own without adopting anything else. If you already depend on Git refs and just want to turn them into commits, that’s fine. Trellis remains modular. Use what you need and ignore what you don’t.
Note that trellis doesn’t make any claims about breaking changes or where they’ll show up. You should still pin according to the amount of change you can absorb. In general, pinning to a major version is safe after 1.0, but for 0.x be sure to check the compatibility promises the library in question provides.
Some may follow a sort of “pre-1.0 SemVer” where breaking changes are promised to come only in minor releases, never in patches. (For what it’s worth,this is what I strongly recommend libraries follow pre-1.0.) In such cases it may be safe to take a minor version-series pin. When in doubt, though, use an exact ref.
Permalink → — and so do others