Open Source

Open Core, Hard Boundaries

Open Core, Hard Boundaries

A private product consumes its own open-source core through public, versioned npm exports, exactly like a stranger would. The design docs spend real effort saying what a package must never become.

An open-core boundary that only exists in a licensing FAQ is not a boundary. It is a marketing page. The boundary is real only when it is architecture — when your own hosted product cannot reach into the open-source core any deeper than a stranger installing it from npm could.

I run close to a hundred open-source packages and a much smaller number of private hosted products built on top of them. The rule that keeps the two from collapsing into each other is simple to state and constantly tempting to break: a private product consumes its own open-source core through public, versioned package exports. Not through a shared database. Not through an internal-only export some future refactor quietly relies on. Through the same npm install anyone else would run.

open-alumia states the rule for itself in its own README: the hosted premium product lives in platform-alumia and must consume this repo through public, versioned @hasna/alumia package exports. That sentence is not aimed at outside contributors. It is aimed at me and at every agent that touches platform-alumia, because the fastest way to break an open-core boundary is convenience — one shortcut import from the private repo straight into the open package's internals, justified as temporary, that never gets removed.

open-gateway draws the line even more explicitly, because a gateway is exactly the kind of package where the temptation to blur public and private is highest. The open-source package is useful entirely on its own: one stable OpenAI-compatible API routing across providers like DeepSeek, Qwen, Moonshot, and Z.AI, with an explicit policy layer so a request is never silently routed to a region or provider class the caller did not allow. The hosted Hasna gateway builds on that same core — but everything that makes it a business stays private: accounts, billing, pooled provider contracts, volume discounts, tenant policy, hosted observability. The open package does not get a crippled version of routing so the hosted product can look better by comparison. It gets the real thing. The hosted product adds the parts that only make sense once money and multiple tenants are involved.

platform-todos is the boundary stated as plainly as it can be stated: it is described, in its own documentation, as a private SaaS wrapper for the open-source hasna/todos core. Nothing more. No secret feature flag that unlocks capability the open CLI cannot reach. The wrapper wraps.

open-microservices makes the same boundary visible at a different scale: 21 independent npm packages — auth, teams, billing, llm, agents, memory, knowledge, guardrails, prompts, notify, files, audit, traces, flags, jobs, and more — each with its own Postgres schema, its own HTTP API, its own MCP server, its own CLI binary. Every one of them can run two ways: embedded, imported straight into an app's process, or standalone, run as its own HTTP service. A hosted product built on top does not get a third, secret mode. It picks embedded or standalone like anyone else installing the package, and it installs only the services it actually needs.

The naming discipline is not decoration, it is the mechanism. A local repo named open-todos mirrors to a public GitHub repo named hasna/todos, which mirrors to an npm package named @hasna/todos, installed as the todos binary. The private consumer is named platform-todos, unmistakably downstream. Anyone reading the two repo names side by side already knows which one is allowed to depend on the other. You cannot accidentally reverse that dependency without the naming itself screaming that something is wrong.

Packages talk to each other the same way a private product talks to them — through public contracts, not shared tables. Events move as HMAC-signed envelopes defined by one package; schemas are defined by another, named contracts, precisely so two services can compose without either one reaching into the other's database. A shared database is the fastest way to make an open-core boundary meaningless, because a table is not versioned, is not documented, and cannot be installed by a stranger. A contract can be all three.

OpenLoops is where the boundary gets written down as an actual design document, because loops sit close enough to automation, workflow, and orchestration that the temptation to let the package's scope creep is constant. Its own architecture doc states the non-goal directly: OpenLoops can execute workflow work that external automation systems have already materialized, but it must not become the automation product surface. Two other packages, automations and actions, own automation specs. OpenLoops owns workflow invocation, admission, execution, run manifests, and provider routing, and only once work has been explicitly handed off to it. The doc does not just say what OpenLoops is. It spends real words saying what OpenLoops must never turn into, because scope creep is how an open core rots into an unmaintainable everything-package that nobody outside the org can safely depend on.

The same document goes one boundary deeper: external compilers should not write OpenLoops database rows directly, even from inside the company. The stable contract is an idempotent CLI or SDK upsert, with dry-run, preflight, and commit modes, gated behind idempotency keys and spec hashes. That rule applies to internal callers as much as external ones. There is no side door for the team that happens to sit closest to the maintainers. If there were, the public contract would just be theater for outside users while insiders quietly depend on something sturdier and more dangerous.

Enforcing this from the inside is harder than writing it in a README, so I made it a direct instruction rather than a hope. Partway through a rebuild of Alumia, the flagship hosted product, I told the team building it — mostly agents — to treat the open-source repositories as SDKs and use them to replace large parts of the app's own core architecture, the same way any outside developer installing the packages would use them. Not import a helper function from deep inside one repo's src directory. Install the package, call its public API, get the same guarantees a stranger gets. If that felt limiting anywhere, the fix was to improve the public package, not to special-case the private product around it.

That single rule — dogfood your own primitives without depending on their internals — does more enforcement work than any amount of written policy, because it is falsifiable in a way policy is not. You can tell within a day of code review whether a product is actually consuming its open-source dependency as a package or is quietly reaching past the public surface. A shared database table used by both the open core and the private wrapper is the boundary already gone, whatever the README still claims.

The boundary shows up as a privacy discipline too, not only a licensing one, in packages that hold anything sensitive. identities, the package that gives humans and agents verified records, exposes a status command that emits a metadata-only reference contract for fleet consumers — counts and opaque references, never names, emails, or keys. That is the same architecture applied one layer lower: even a package's own status output must not leak what its private callers are allowed to see and its public callers are not. Redaction is not a feature bolted onto the API. It is the boundary, expressed as a return type.

The boundary has a real cost, and pretending otherwise is how people stop enforcing it. Sometimes the hosted product wants a small convenience the public package does not expose yet, and the honest fix is slower than the shortcut: add the capability to the public package first, publish a new version, then consume that version in the private product. That is friction on purpose. It means every feature the flagship product gets, the open-source community gets at the same time, through the same versioned release, not as a private fork of a shared codebase that quietly diverges.

It also means the public package has to be genuinely good, not merely good enough to demo. If the open-source core is not sufficient for my own hosted product, it is not sufficient to publish and ask strangers to build on. That test runs in both directions: a boundary that is too strict starves the private product of features it needs, and a boundary that is too loose turns the open-source release into a hollow shell with all the real logic hidden behind a paywall. The correct boundary is the one where the private product has nothing the public package's own users could not build for themselves, given the same package.

Catching a violation before it ships is mostly a matter of making the shortcut visible. A relative import that reaches from a private repo across a filesystem boundary into an open package's src directory, instead of through its published entry point, is easy to grep for and easy to flag in review. A parity check that fails the moment the private product's behavior no longer matches what the public package alone would produce is easy to run in CI. The discipline does not require trusting anyone's judgment in the moment. It requires making the boundary something a script can verify, so the review is a diff, not an argument.

What you get in exchange for the friction is a public artifact that means something. When someone installs @hasna/gateway or @hasna/todos or @hasna/loops, they are running the exact code path my own products run, not a stripped preview built to make the hosted version look better by comparison. The design docs that state what a package must not become are not there to constrain outside contributors. They are there to constrain me, on the days when the fastest way to ship a feature in the private product is to cheat on the boundary I set for everyone else.

There is a test I hold every hosted product to that follows directly from the boundary: if it disappeared tomorrow, could someone rebuild everything it does from the public packages alone, with nothing missing but the billing and the convenience of not running it themselves? For platform-todos, platform-alumia, and the hosted gateway, the honest answer is yes, because the boundary never let them accumulate capability the open core does not also have. That is a different kind of moat than the one people usually build — not lock-in, but the opposite of lock-in, kept deliberately, because the alternative is a hosted product that only looks impressive as long as nobody can see what it is actually made of.

A hard boundary between what is open and what is hosted is not a compromise between idealism and business. It is what makes both halves trustworthy at the same time: the open half because a stranger can verify nothing was held back, the hosted half because everything it does can be checked against the package that powers it. One boundary, both directions.

← Back to the articles

Newsletter

What we shipped, what broke,
and what we learned