← All churts

uBixCore — Thirteen Tags in Four Days, and the Rule That Made It Safe

uBixCore — Thirteen Tags in Four Days, and the Rule That Made It Safe

A Framework Being Pulled, Not Pushed

Between Saturday and Tuesday, uBixCore went from v0.21.0 to v0.33.0. Thirteen tags.

I didn’t plan a single one of them. Every tag exists because Sowing.me — the membership platform I’m building on top of it — walked into something the framework couldn’t do yet. A payment seam that couldn’t say what the processor charged. An object store that wouldn’t let me choose a key. A class I couldn’t write a test for.

That’s a genuinely good way to develop a framework. It’s also the fastest way I know to wreck one, because the pull is always toward “just put it upstream, we need it.” Do that for a month and your framework isn’t a framework — it’s your product with the interesting parts renamed.

So there’s one question I ask before anything moves from Kitg\ into Ubix\:

Would a completely unrelated product built on this — not a membership platform, not Christian, not creator-facing — want this code verbatim?

If explaining the code needs one of my product’s nouns, it stays in my product. No exceptions for “we’ll need it in the second platform too” — a second platform sharing code is a reason to keep it in the product, not to push it into the framework.

That rule is why thirteen tags in four days didn’t turn uBixCore into Sowing.me.

What Actually Landed

The theme, looking back at it, is seams. uBixCore now puts every outside vendor behind an interface, with the vendor itself in a separate implementing class:

SeamImplementation
PaymentsStripe
Object storageS3 / MinIO
Sign-inOIDC providers
Real-timecore NATS
Email, SQL, subprocessesthe obvious ones

Exactly one file in the whole framework imports the Stripe SDK. I checked that claim rather than assuming it, which turns out to be a theme too.

A few of them are worth the detail.

The ledger was quietly lying. Sowing.me’s pricing pages promise that Stripe’s processing fee comes out before a creator’s payout. The ledger recorded my platform commission and nothing of what Stripe charged — so every creator’s balance, and every payout drawn from it, was over by the card fee. A real $25 test tip proved it: two rows, tip 2500 and fee -250, and nothing else.

The fix needed the framework to answer “what did the processor actually charge on this payment?” — which no webhook carries, so it’s a lookup, not event parsing. That’s getSettlementForPayment(), and it’s a good example of the boundary working: the framework learned to ask the question, and my product still owns every decision about what to do with the answer.

Relative filenames beat clever URLs. I added hosted video, which means HLS, which means a playlist that names its segments by relative filename. uBixCore’s object storage minted its own opaque keys on purpose — so one caller can’t guess another’s — and a playlist written to a generated key can’t reference segments it can’t name.

I spent a while trying to be clever, rewriting playlists to absolute signed URLs, before noticing that those URLs expire during playback. The answer was a single new method, putObjectAt(), that takes a key from the caller — with the loud docblock explaining that it’s the one exception and why. The generated-key version is still the default for single opaque objects.

A class I couldn’t test. ProcessService — the thing that shells out — was final with no interface. So any class that builds a command line and reads an exit code had no way to be exercised without running the real binary. One of my own services worked around it by splitting the parsing away from the call, and said so in a docblock.

Adding the interface should have been ten minutes. It wasn’t, because two of my own house rules collide: the standards test wants a service interface to end ServiceInterface and the class to end with the interface’s prefix, while the phpcs standard wants the interface imported under its name minus Interface — which is the concrete class’s own name. ProcessService implements ProcessServiceInterface is literally unspellable in my own codebase.

I resolved it the way the framework already resolves it for storage: the interface takes the plain name, and the implementation is named for how it works — NativeProcessService, beside S3MediaStorageService. Every call site’s type hint stayed identical; only the use line moved, in eleven files. It’s a breaking rename for anyone naming the old class, which today is exactly one codebase: mine.

Two Safety Rails I’d Ship Again

Neither of these is glamorous and both have already saved me.

The database says which tier it is. Every database carries its own environment label, and migrate:up refuses one labelled for another tier. Restore a production clone into staging and you cannot migrate it as staging until you relabel it deliberately. The check is the database’s own answer, not a variable a pipeline could have set wrong — which is the entire point, because the pipeline variable is the thing that’s wrong when this matters.

Drift you can actually gate on. migrate:diff replays the migrations into a scratch schema and compares it against the live one. Simple idea, useless in practice at first: it reported 108 differences on a clean database, because MariaDB renders things back at you that you never wrote — per-column collation matching the table default, auto-generated JSON checks, a default row format that the server has defaulted to since 10.2.

Normalising that noise away took three tags. It was worth it, because 108 differences is a wall of text nobody reads and 0 differences is a blocking CI job — and the first time it ran clean it immediately found two real defects, including a column my code selected that no migration created. A fresh database would have 500’d on login.

The Part I Got Wrong

Worth saying plainly, because it’s the most useful thing here.

I shipped hosted video across four merge requests. Every class unit-tested, PHPStan at level max, the whole gate green, reviewed. Then I put an actual video file through it and it failed on the first try — the clip had no audio track, and the HLS stream map named an audio stream that didn’t exist, so ffmpeg refused. A silent screen recording would have done the same to any creator.

Every one of those unit tests stubbed the process runner. So every one of them agreed with the bug. A stub cannot tell you that ffmpeg rejects a stream map describing a track the file doesn’t have.

Then the same real-file run found a second one: the playlist endpoint returned 401 to anonymous visitors, so a public video didn’t play for the public. That was the third time I’d hit that exact class of bug — a route with no role guard, reading perfectly correct, refused by middleware before its handler ran. This time the fix came with a test over the whole list of routes a person with no account must reach, each with the reason it must. That list is a promise about the product, not about the code.

The seams didn’t prevent either bug. What they did was make both fixable with a test afterwards — which is the honest case for interfaces. They don’t make you right. They make being wrong cheap to correct.

Where It Is

Still pre-1.0, still with one real consumer, and it has no CHANGELOG — which after thirteen tags including a breaking rename is an obvious gap and mine to fix.

Details of what each seam does are on the uBixCore page; the source is on GitHub under BSD-3-Clause.

← All churts