A single-binary backend core: namespaced document collections and typed settings over embedded SQLite, driven from the CLI.
No database server, no runtime, no admin UI, no container. One static binary that is the server, the client, and the migration runner.
New here? Start here — 60 seconds: a multi-user JSON API where every row belongs to whoever created it and nobody can read anyone else's, with no application code at all.
Seven primitives and a sandboxed script runtime. Documents, settings, identity, files, an event log, a scheduler and signed webhooks — the parts every backend ends up rebuilding. Anything domain-shaped is a JavaScript file you install, not a feature the core grows.
If you want a UI, use PocketBase. It is the closest thing to this and it is very good: one Go binary, embedded SQLite, realtime subscriptions, and a genuinely nice admin dashboard. bkn makes the opposite trade deliberately — no dashboard, a CLI that is the interface, and a sandboxed script runtime as the extension point instead of Go hooks. If a human is going to click through your data, PocketBase is the better tool.
go build -o bin/bkn .
bkn guide # the mental model, embedded in the binary
bkn help-json # machine-readable command catalog
bkn store create myapp/users --normalize email=trim_lower
bkn store put myapp/users --data '{"email":" [email protected] ","name":"Ada"}'
bkn store find myapp/users --where [email protected]
export BKN_ENCRYPTION_KEY=$(openssl rand -hex 32)
bkn kv set myapp.stripe sk_live_xxx --type encrypted
bkn kv get myapp.stripe
echo -n 'correct-horse-battery' | bkn auth user create [email protected] --password-stdin
bkn auth org create acme --name "Acme Inc"
bkn auth member add acme [email protected] --role owner
echo -n 'correct-horse-battery' | bkn auth login [email protected] --password-stdin --org acme
bkn script test --file digest.js --input '{"limit":10}'
bkn script create waitlist-digest --file digest.js
bkn script run waitlist-digest
bkn script update waitlist-digest --run-access user # signed-in callers may run it over HTTP
bkn daemon start # the same primitives over HTTP
curl -s localhost:7799/_healthThis is the decision people push back on, so it deserves an argument rather than a slogan.
In the system audited above, the UI was 30% of it and the least testable part. That admin was 83 EJS templates, ~36,000 lines — comparable to its entire service layer. None of it was covered by a test, none of it could be driven by anything but a person, and every feature had to be built twice: once as an endpoint and again as a screen.
A UI is a client, not an interface. Every one of the nine domains in
examples/ turned out to be reachable as CLI verbs over a JSON
contract; the admin screens
were a second, unversioned client of that contract. bkn keeps the contract and
drops the second client. The HTTP API is still there — if you want a dashboard,
build one against it. bkn just declines to make it the product.
A CLI can be introspected; a UI cannot. bkn help-json returns every
command, flag, exit code and environment variable as JSON. bkn guide returns
the mental model, the loop, the concepts and the gotchas — embedded in the
binary, offline. An agent that has never seen this tool learns to drive it in
two calls. There is no equivalent for a screen: the only way to find out what a
dashboard does is to look at it.
Composability is the whole point.
bkn store list shop/orders --where 'total>100' --order-by total:desc | jq -r '.records[] | [.id, .total] | @tsv'That is a sentence. The dashboard equivalent is a screenshot.
The honest counterargument. A UI is genuinely better for browsing data you cannot yet describe, and it lets non-technical operators work without a terminal. bkn loses both. That is a real cost, and if it matters to you, PocketBase already solved it well. This is a scope decision about what belongs in the core — not a claim that UIs are bad.
The primitive list is short because it was cut down to what gets used, not guessed at. The evidence is an audit of a real production backend — superbackend, 85k lines of Node/Express/MongoDB grown to ~40 admin domains — where every consumer was examined to see what it actually called. That audit found:
- only six store operations were ever used — get, put, patch, delete, find by field, list — with no aggregations, joins or server-side sorts;
- consumers routinely bypassed the HTTP API and opened the database directly, because the API did not expose what they needed;
- one consumer imported the backend's internal service files, and its Mongoose instance, by absolute filesystem path.
The lesson was not "port the 40 domains". It was that features multiply when the core lacks an escape hatch. So the core is small on purpose — store, kv, and script — with everything else built on top rather than beside them.
Small is not the same as limited. The bar for any application is not "could this run on bkn" but "would this be less code on bkn" — an application bkn cannot serve is a gap to close, not a boundary to defend. What keeps that from growing an 85k-line core again is a single admission rule, in VISION.md: admit a primitive that removes a class of application code from every embedder; refuse a query feature that only moves application code into bkn.
Fit-checked against two real Go codebases, counting distinct SQL statements and
how many exceed what store offers:
| codebase | statements | beyond the allowance | genuinely relational |
|---|---|---|---|
| superlandings-go — 8k LOC, 8 tables | 48 | 3 | 1 |
| automaintainer-saas-panel — 131k LOC, 20 tables | 202 | 33 (16%) | ~10 (5%) |
The large one is the informative one: of its 33 outliers, only the 8 joins and 2 subqueries are relational. The rest is atomicity and summarisation.
patch used to read a document, merge in Go, and write the whole thing back.
Two concurrent patches of different fields kept only one of them — 16 concurrent
increments landed as 1, and every contender for a job "won" it.
A patch field may now be an operator, computed from the field's current value:
bkn store patch app/runs r1 --data '{
"tries": {"$inc": 1},
"log": {"$append": "started\n"},
"stages": {"$push": "build"},
"worker": {"$setIfEmpty": "w1"}
}'The write is a compare-and-set against the exact document the merge was computed
from, retried on a lost race — so concurrent patches accumulate instead of
erasing each other, across processes and not merely within one. A missing field
is the operator's identity: incrementing an absent counter yields the operand.
A plain object is still a plain value; only a single $-prefixed key is an
operator.
Preconditions guard a write, and are how one worker claims a job:
bkn store patch app/runs r1 --data '{"status":"done"}' --if status=running
bkn store patch app/runs r1 --data '{"worker":"w1"}' --if-absent workerConditions are ANDed and checked against the document being written; a failed
one writes nothing and exits 95 (precondition_failed). There is deliberately
no OR, matching the query surface — an either/or guard belongs in your control
flow, not in a predicate language growing inside bkn. The same options are query
parameters over HTTP (?if=status=running, ?if-absent=worker, 409 on
failure) and an optional fourth argument in scripts.
These are primitives, not query features, by the test in
VISION.md: they remove read-modify-write races from
every caller, and userland cannot implement them safely — a script that reads,
adds one and writes back is the race. The core needed them first: cron's
claim() compare-and-sets a job's next run in one UPDATE for exactly this
reason, and applications had no way to say the same thing.
A control plane's first dashboard question is a rollup:
SELECT status, COUNT(*) FROM explore_candidates
WHERE repo_id=? AND user_id=? GROUP BY statusbkn store count app/runs --where repo_id=7 --by status{"by":"status","total":41,"groups":3,"truncated":false,
"buckets":[{"key":"ok","count":30},{"key":"failed","count":8},{"key":"stale","count":3}]}Without --by it is a plain total. With it, one bucket per distinct value,
largest first — the same shape and ordering events stats --by already uses,
because a store is no less entitled to a rollup than a log is.
total counts matching documents and groups counts distinct values before
any limit, so truncation is visible rather than silent: a rollup on a
high-cardinality field returns capped buckets and still tells you how many
groups there really were. A document with no value for the field gets a null
key, which is deliberately different from an empty string.
The alternative was listing the collection and counting in the caller — which
is precisely what rule 2 rejected when it admitted ordering and ranges, because
doing them in a script means loading the collection into the VM. A rollup is
admitted on the same ground, and stays a summarisation rather than a query
language: one field, one aggregate, no expressions, no HAVING, no joins, and
it never returns documents.
Over HTTP the same question is ?by=status, alongside the filters — by and
by_limit join limit and order_by as reserved parameter names. In a script
it is bkn.store.countBy(ref, where, field).
An unbounded log table is a disk-space incident waiting to happen, so every codebase grows a trim query and a job to run it. One 131k-line control plane ran this twice:
DELETE FROM repo_memories WHERE tag=? AND repo_id=? AND user_id=?
AND id NOT IN (SELECT id FROM repo_memories WHERE tag=? AND repo_id=?
AND user_id=? ORDER BY created_at DESC LIMIT ?)Declare the bound on the collection instead:
bkn store create app/runs --retain-last 500
bkn store create app/memories --retain-last 20 --retain-per tag,repo_id,user_id--retain-last N keeps the newest N documents. --retain-per gives each
distinct value of those fields its own N, which is what the query above was
doing by hand. The store enforces the bound on every write and again the moment
the policy is declared, so a bound set on a collection that already holds a
million rows applies immediately rather than at the next write.
That deletes two things from every caller: the query, and the schedule that ran
it. No bound means unbounded, exactly as before. --retain-per without
--retain-last is rejected rather than accepted-and-ignored — a policy that
reads like a policy and enforces nothing is worse than no policy.
Ordering is created_at, then insertion order. The tiebreak matters because
timestamps have second resolution: without it, documents written in the same
second would be evicted arbitrarily.
Retention is declared on the collection, like --normalize, and a collection
is declared either from the CLI or with PUT /v1/store/<ns>/<coll> — which is
how a deployment behind a reverse proxy, with nobody logged into the box, gets
one at all.
A collection declares how its fields are normalized, and the store applies the rule on every write and to every filter value for the same field, so a lookup finds the row a normalized write created:
bkn store create app/users --normalize email=trim_lower
bkn store create app/reports --normalize declarant.email=trim_lowerA field may name a nested key with dots. The filter side already addressed
documents that way (--where declarant.email=...), and until both halves
agreed, a nested rule was accepted and then silently skipped on write — which
left the document unfindable by the very field its collection declared as
normalized, because the lookup value was normalized and the stored value was
not.
A path the store cannot address — array indices, quoting, wildcards — is refused when declared rather than accepted and ignored. Rule 5: fail loudly, never downgrade.
auth holds users, organizations, memberships and tokens. Emails are
normalized in exactly one place. Passwords are bcrypt, so hashes written by the
Node backend's bcryptjs keep verifying — checked in both directions.
Access tokens are short-lived HS256 JWTs carrying sub, email, role, org
and org_role. Refresh tokens are opaque, stored only as a digest, and
single-use: every refresh rotates them, so a stolen one dies the moment the
real holder refreshes. Changing a password or disabling a user revokes that
user's sessions immediately.
Platform roles (user, admin) govern the deployment. Organization roles
(owner > admin > member) govern a tenant. A platform admin is not
implicitly an owner of anybody's organization.
What auth deliberately does not hold is billing. In the backend audited
above, subscriptionStatus, currentPlan and stripeCustomerId lived on the
user record, and the absence of an endpoint to write stripeCustomerId is what
drove a consumer to bypass the API and write MongoDB directly. Billing goes in
a store collection:
bkn store put billing/subjects --id <user-id> \
--data '{"plan":"pro","status":"active","stripe_customer_id":"cus_x"}'Every data route used to require the admin token, so a browser or a phone could not talk to bkn at all. The application in between had to re-implement the same three lines — verify the token, read the subject, add the tenant filter — at every endpoint, and the day one of them was forgotten, that endpoint returned everybody's rows.
A collection declares who may do what, and bkn enforces it:
bkn store create app/notes --owner-field user_id \
--access read=owner,create=owner,update=owner,delete=owner
bkn store create app/docs --org-field org_id --access read=org,create=org
bkn store create site/pages --access read=publicFour verbs — read (get, list and count), create, update, delete — each
mapped to one of five audiences:
| audience | who | scoped to |
|---|---|---|
admin |
the admin token | everything |
user |
any valid access token | everything |
owner |
a token holder | documents whose --owner-field is them |
org |
a token holder | documents whose --org-field matches their token's org |
public |
anybody | everything |
admin is the default, so nothing changed for a collection nobody has
spoken about. A policy grants and never revokes: --access read=public
leaves writes admin-only, because an undeclared verb is admin.
The part that removes bugs rather than catching them is that a scoped create takes its tenancy from the token, not from the body:
curl -H "Authorization: Bearer $ADA" -d '{"title":"hi","user_id":"SOMEBODY-ELSE"}' \
$HOST/v1/store/app/notes
# -> {"record":{"id":"...","title":"hi","user_id":"<ada's id>"}}A document cannot be written into the wrong tenant, because the tenant is not
something the client gets to say. The read side is the same idea: the scope is
part of the query, not a check beside it, so ?user_id=someone-else narrows an
already-scoped page to nothing rather than widening it — filters are ANDed and
there is no OR to escape through. A rollup (?by=) is scoped too, or it
would leak the count of what you cannot read.
Three refusals are worth stating, because each one is a way this normally goes wrong:
- A scoped read, patch or delete of another tenant's document answers 404, not 403. A 403 confirms the id is real to somebody with no right to know it.
- A caller-supplied id cannot take a document.
POST ...?id=<theirs>is an upsert, and a create policy stamps your id onto it — so without a guard it would replace their document and relabel it as yours. The insert and the ownership test are one statement (ON CONFLICT ... WHERE), not a check followed by a write. - An
orgaudience refuses a token carrying no organization. Reading it as "unscoped" would hand every tenant's documents to somebody who merely forgot to pick one — the exact failure the mechanism exists to prevent.
Scoped updates reuse machinery that was already there: the scope becomes a
precondition on the compare-and-set that patch already performs, so a
document that changes hands mid-flight fails the write instead of passing a
stale test.
This is admitted by the rule in VISION.md — it
removes a class of application code from every embedder, and userland cannot
implement it safely, because a filter the caller has to remember to add is a
filter somebody eventually forgets. What it is not is a rule language: an
audience is one word from a closed set and a scope is a field name, never an
expression, for the same reason the query surface refuses OR.
Scoping the data was necessary and not sufficient. Three things were operator- only because the only client had ever been an operator:
POST /v1/auth/register # self-service signup, off unless BKN_OPEN_SIGNUP=1
POST /v1/auth/password # change your own, verifying the current one
POST /v1/auth/orgs # create a workspace; the creator becomes owner
POST /v1/auth/orgs/{org}/members # invite, gated by org role, not by the admin token
GET /v1/auth/orgs/{org}/members
DELETE /v1/auth/orgs/{org}/members/{user}
PUT /v1/store/{ns}/{coll} # declare a collection: normalizers, retention, accessRegistration will not let a caller name their own role, or it would be an
admin-account vending machine, and it shares the login throttle, since it is
the other way to learn whether an address has an account. Any signed-in user
may create an organization and becomes its owner — that is the "create a
workspace" move a product needs, and the deployment-level control over it is
BKN_OPEN_SIGNUP: with signup closed, the only accounts that exist are ones an
operator made. A password change
verifies the current password rather than trusting the access token, because a
stolen token is exactly the situation where it must not be enough.
Browsers need one more thing: BKN_CORS_ORIGIN is an explicit allow-list,
unset by default. It is not a reflected origin — bkn authenticates with a
bearer header, and a permissive default would let any page on the internet
spend a token it managed to obtain.
Namespaced blob storage. A namespace declares its backend (local or s3),
a per-file size cap, the content types it accepts, and whether its files are
served over HTTP without auth.
bkn files ns create avatars --allow-type 'image/*' --max-bytes 1048576 --public
bkn files put avatars ./ada.png
curl -s localhost:7799/v1/files/avatars/ada.png -o ada.png # no authBytes are stored under their SHA-256, never under the caller's filename. That is not a flourish: it means no user-supplied name ever reaches the filesystem, so directory traversal is impossible by construction rather than by validation. Identical uploads deduplicate, and the hash doubles as the ETag. Two names sharing one blob are reference-counted — deleting one keeps the other readable.
Serving user uploads inline from the API's own origin is a stored-XSS delivery
mechanism, so only a short allow-list of types is served inline (common images,
text/plain, audio, video). HTML, SVG, PDF and JavaScript are served as
attachments with X-Content-Type-Options: nosniff and
Content-Security-Policy: default-src 'none'; sandbox. A file in a non-public
namespace answers 404 to an unauthenticated caller, exactly like one that does
not exist.
A name is claimed and written in one statement. files put refuses an
existing name with already_exists unless --overwrite, and that refusal used
to be a lookup followed, some way down, by an unconditional upsert — so two
uploads of the same free name both saw it free, both wrote, and the second
silently replaced the first. Nothing looked wrong afterwards: the name still
resolved, it just pointed at the other file. A test runs twelve writers at one
name and asserts exactly one wins; against the previous code all twelve
reported success.
A namespace may also decide a file's type from its bytes rather than from what the uploader said:
bkn files ns create avatars --allow-type 'image/*' --verify-typeWithout it the allow-list is checked against a string the caller sent —
declare Content-Type: image/png over any bytes at all and an image-only
namespace accepts them and records them as an image. That is tolerable while
the only writer holds the admin token, and stops being tolerable the moment a
tenant can upload.
The cost is worth stating rather than hiding, because it decides whether you
want the flag: a sniffer sees bytes, not intentions. A namespace that
verifies types allow-lists what files look like, not what they are called. A
.docx is a zip and sniffs as application/zip; a .css full of words sniffs
as text/plain. A declared type is kept when it is a more specific truth about
bytes the sniffer can only call a container — docx over zip is accepted,
image/png over zip is not. And bytes resembling no known format sniff as
application/octet-stream, which is the sniffer declining to answer rather
than evidence of a lie, so arbitrary binary can still be declared as anything
the allow-list permits. What the flag removes is the whole class the sniffer
can name: HTML, JavaScript, SVG, and every text format a browser would act
on. It is off by default, so existing namespaces are unchanged.
The s3 backend speaks the S3 REST API directly with hand-rolled SigV4 — no
SDK, because object storage needs three verbs and one signing algorithm, and
the official client pulls in a dependency tree larger than this program. It is
verified against a live MinIO in the test suite:
docker run -d --rm -p 9123:9000 -e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin quay.io/minio/minio server /data
BKN_S3_TEST_ENDPOINT=http://127.0.0.1:9123 go test ./internal/files/One append-only log for what used to be three admin domains. Errors, audit trails and counters differ only in which field you care about, so an event has a stream, a type, a level, a source, a subject and a JSON body.
bkn events emit errors http.500 --level error --source api --subject /v1/orders
bkn events stats errors --by subject --since 24h
bkn events list errors --level error --limit 20
bkn events prune --older-than 30dThere is no automatic retention: the log grows until something prunes it.
That is deliberate — deletion should be something someone asked for — and
prune on a cron job is the usual way to ask.
A schedule bound to a script. The scheduler is thin because script already
runs code safely with a timeout and a run history; it only has to answer "what
is due".
bkn cron create nightly --schedule '@daily' --script retention \
--input '{"stream":"errors","older_than":"30d"}'
bkn cron list
bkn cron run nightly # immediately, without consuming the scheduled slotStandard 5-field expressions plus @hourly/@daily/@weekly/@monthly/
@yearly and @every 5m. Jobs fire while bkn serve runs; bkn cron tick
performs exactly the same pass once, for anyone who would rather drive the
timing from systemd or a real crontab. Overlapping runs are skipped rather than
queued, and every run is recorded in the cron event stream.
bkn.http.fetch lets a script call out. hooks lets the world call in.
bkn hooks create stripe --script stripe-webhook
# -> /v1/hooks/stripe PUBLIC and unauthenticatedThis route is deliberately public, because providers authenticate with a signature header rather than a bearer token. Without it, no signed integration could ever be a script and every one of them would have to be Go code — which is the thing the sandbox exists to avoid.
The script receives the body byte-for-byte, the lower-cased headers and the
query string, and returns {status, body, headers} to shape the reply. It is
the only thing between the internet and your data, so it must verify the
signature — bkn.crypto exists for that:
const expected = bkn.crypto.hmac(secret, parts.t + "." + d.body);
if (!bkn.crypto.equal(expected, parts.v1)) return { status: 400, body: { error: "bad signature" } };bkn.crypto.equal is constant-time; === leaks the correct prefix through
timing. Digests are verified against Node's crypto in the test suite, since
that is what the providers document against.
A script that throws answers 500 so the provider retries, rather than silently swallowing a delivery.
An expiring lease for work that must not overlap across processes — the
scheduler's in-process guard says nothing about a CLI cron tick racing the
daemon, or two deployments sharing a database.
const lock = bkn.lock.acquire("blog-automation", 900);
if (!lock) return { skipped: "already running" };
try { /* … */ } finally { bkn.lock.release(lock.key, lock.owner); }Acquisition is a single conditional statement, so a read-then-write race cannot produce two holders; there is a test that runs 24 goroutines at one key and asserts exactly one wins.
Most of those 40 domains were a scheduled HTTP call, a transform over stored records, or a webhook handler. Given a sandboxed runtime with access to the other primitives, they are scripts:
function main(input) {
const subs = bkn.store.list("marketing/waitlist", { limit: input.limit || 100 });
bkn.kv.set("marketing.last_digest", bkn.now());
return { total: subs.length };
}A script must define main(input); its return value is the run's result. It
gets bkn.store, bkn.kv, bkn.auth, bkn.files, bkn.events, bkn.lock,
bkn.crypto, bkn.http.fetch, console.log, bkn.id() and bkn.now() —
and nothing else. No filesystem, no processes, no timers, no
require, and no network beyond its own allow_net list. Every run is bounded
by timeout_ms and recorded with its status, logs, result and duration.
Outbound requests that resolve to a loopback, link-local or private address are
refused even when the hostname is allow-listed, which is what stops an
allow-listed name from reaching the cloud metadata endpoint.
BKN_SCRIPT_ALLOW_PRIVATE_NET=1 lifts that deliberately.
There is no event loop: bkn.http.fetch blocks and returns its response, and
there are no promises or async/await.
The trust model is worth stating plainly: scripts are operator code, not
tenant code. A script already reads decrypted secrets through bkn.kv, and
bkn.auth.issue can mint a session for any user — which is what makes SSO
callbacks and invite acceptance scriptable — and bkn.auth.login verifies a
password, so a hook can compose password sign-in with its own policy
(whitelists, org checks, audit events). Review a script the way you would
review core.
Built to the agent-first CLI spec family — https://cli-specs.intrane.fr.
| Spec | Status |
|---|---|
| cli-output-spec | stdout = data, stderr = context, exit codes 0/80–119, typed errors with recoverable + suggestions, help-json, no internal retries |
| cli-guide-spec | bkn guide (embedded, --human markdown), GET /guide, GET /llms.txt |
| cli-daemon-spec | bkn serve --host --port (loopback default), GET /_health, POST /_shutdown (token-gated off-loopback), bkn daemon start|stop|status (idempotent, health-probed) |
| cli-update-spec | bkn update [--check] [--force] — content-hash versions, verify + smoke-test before an atomic swap, .bak kept; bkn install/uninstall (§6); throttled passive nudge; GET /version + /dl/bkn when serving |
| cli-feedback-spec | bkn feedback — dual-write to the app endpoint and the relay under one id, never fails the caller; POST /v1/feedback open, capped, rate-limited, idempotent |
| cli-telemetry-spec | bkn telemetry [--on|--off] — opt-in, allow-listed payload, disclosed before the first send, honours DO_NOT_TRACK and CI |
| Variable | Purpose |
|---|---|
BKN_DATA |
datastore path (default ~/.bkn/bkn.db) |
BKN_HOST / BKN_PORT |
serve bind defaults (127.0.0.1 / 7799); flags win |
BKN_ADMIN_TOKEN |
bearer token gating every admin HTTP route; required to bind off-loopback |
BKN_CORS_ORIGIN |
comma-separated origin allow-list for browser callers (* permitted, and echoed rather than returned literally); unset means no cross-origin access |
BKN_OPEN_SIGNUP |
1 enables POST /v1/auth/register |
BKN_FILES_DIR |
where local blobs live (default ~/.bkn/files) |
S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
enable the s3 backend; BKN_S3_* also accepted |
BKN_AUTH_SECRET |
token signing secret; generated and stored in kv if unset |
BKN_SCRIPT_ALLOW_PRIVATE_NET |
1 lets scripts reach loopback and private addresses |
BKN_ENCRYPTION_KEY |
single key, hex-64 / base64-32 / 32 literal chars; stamped as keyId v1 |
BKN_ENCRYPTION_KEYS |
v1:<material>,v2:<material> — every key that can decrypt |
BKN_ENCRYPTION_KEY_ID |
which key encrypts new values |
SUPERBACKEND_ENCRYPTION_KEY, SAASBACKEND_ENCRYPTION_KEY |
read as fallbacks so values written by the Node backend decrypt unchanged |
The payload format is byte-compatible with the Node implementation it replaces and is verified in both directions:
{"alg":"aes-256-gcm","keyId":"v1","iv":"<b64>","tag":"<b64>","ciphertext":"<b64>"}keyId was present in the original format but nothing ever read it, so
rotation was impossible in practice. bkn kv rekey implements it: add a key,
point BKN_ENCRYPTION_KEY_ID at it, rotate. Entries sealed by a key that is no
longer configured are reported rather than skipped, and do not block rotating
the rest.
Nine domains that are whole services in most stacks run here as scripts, with
no Go code added for any of them — see examples/:
| Domain | Node | bkn |
|---|---|---|
| Blog automation | ~2,500 lines, 3 models | 234 lines of JavaScript |
| Stripe webhook | ~1,500 lines, 2 models | 159 lines of JavaScript |
| Forms | forms service, controller, model | 154 lines of JavaScript |
| Waiting-list exports | ~900 lines, two services | 120 lines of JavaScript |
| i18n | 2 services, 2 controllers, 2 models | 240 lines of JavaScript |
| Page redirects | service, admin routes, model | 110 lines of JavaScript |
| Feature flags | service + controller | 138 lines of JavaScript |
| JSON configs | service + controller + model | 144 lines of JavaScript |
| Headless CMS | ~780 lines, 2 models | 422 lines of JavaScript |
That is the test the core was built for. Porting them surfaced gaps — no
crypto in the sandbox, no public inbound route, no atomic conditional write, a
binary-unsafe fetch, then GET hooks, non-JSON responses, CORS and rate
limiting — every one of which became a general primitive rather than a
one-off patch. Four consecutive domains needed no core change at all; the
headless CMS finally did, and got a bounded query surface rather than a query
language.
The version is sha256[:12] of the binary, so nothing needs bumping — identical
bytes are the same version.
./bkn install # copies the binary you have onto ~/.local/bin
bkn update --check # exit 5 means an update exists
bkn update # verify hash -> run it -> swap -> keep .bakThe download is verified and executed before it replaces anything: a
truncated publish can still match its own hash, and running it is the only
check that catches that. The previous binary stays at <path>.bak, so a
rollback is one mv.
A running bkn serve publishes updates for other machines from
BKN_RELEASE_DIR, via GET /version and /dl/bkn.
bkn feedback "..." dual-writes to this instance and to a central relay under
one id, so a retry is stored once. It never fails the caller — losing a bug
report because reporting it failed is the worst outcome — and
FEEDBACK_RELAY=off keeps submissions local.
Telemetry is opt-in, not opt-out. The spec permits either and asks for
opt-in from tools handling credentials; bkn holds encrypted secrets, password
hashes and token signing keys, so nothing is sent until bkn telemetry --on.
bkn telemetry prints the exact payload it would send, and
BKN_TELEMETRY_URL redirects it — so what it sends can be observed without
letting it send.
$ bkn telemetry
enabled: False | reason: not enabled; bkn is opt-in (bkn telemetry --on)
payload: {tool, version, event, verb, os, arch, exit_class, ts}
install_id is omitted entirely, which the spec calls the safer default.
No collector is running yet. The endpoint follows the convention the rest
of these tools use (feedback.intrane.fr/v1/telemetry), but the relay
currently serves only /v1/feedback, so an enabled sender posts into a void —
silently, with no retry and no effect on the exit code, which is exactly what
the spec asks for from an unreachable collector.
The reference deployment is bkn.intrane.fr — systemd on loopback, Traefik for
TLS, Cloudflare for DNS. See docs/deploy.md.
One hazard is worth repeating here. serve binds loopback and, with no
BKN_ADMIN_TOKEN, treats that as "only a co-resident process can reach me" —
true on a laptop, false behind a public reverse proxy, which is the normal
way to deploy this. bkn refuses the loopback exemption for any request carrying
a forwarding header and warns at startup, but set BKN_ADMIN_TOKEN regardless:
it is the thing that actually gates the admin routes.
$ curl -s -o /dev/null -w '%{http_code}\n' https://bkn.intrane.fr/v1/kv
403
The core primitives — store, kv, auth, files, events, cron,
hooks, lock and script — are complete and tested, and nine real domains
have been ported onto them.
With collection access policies, a browser or mobile client can talk to bkn directly: identity, tenancy, self-service signup and workspace membership are all reachable over HTTP. Three things are honestly not:
- Files are not scoped. A namespace is
--publicor admin-only; there is no per-user upload, so avatars and user attachments still go through a hook script that does its own checking. The two things that had to be true before that could change are now true — a name cannot be taken by a racing writer, and a namespace can refuse bytes that contradict their declared type — but the policy layer itself is not built. Note that scoping would not remove a hook like the signalement one: its real work is relational (does this record exist, does it already have five) and no namespace policy expresses that. - There is no realtime. No websockets, no server-sent events. Anything built on presence, live cursors or a chat feed is not a bkn application.
- There is no text search. Filters are equality, ranges and
in; a search box over content has nothing to call.
The first is a gap. The other two are scope decisions, and closing either one would be a different program.
The claim that the core is the right size is supported by one fit-check against the codebase bkn was derived from, which is weak evidence by construction, and by a second against a domain bkn had no hand in: creneau, a booking backend chosen because its hard parts (never double-book, with no transactions; reschedule, with no primitive for it at all) are ones bkn claims to handle. Its pass/fail criteria were committed before any of its code was written.
creneau shipped, and its result is incomplete rather than positive
(results).
Every criterion that could be evaluated holds: one core change admitted against
a ceiling of three, 137 lines of compensating script against a ceiling of 150,
and zero double-bookings under sustained concurrency, asserted in SQL against
the datastore rather than against the API's own answer. Two could not be
evaluated. And five of its twelve in-scope features were never built — which
matters more than the passes, because they are the ones that would have
exercised cron, files, hooks and the access policies. The benchmark
reached store and script and nothing else, so it is evidence about two
primitives out of nine.
Its most useful finding was not predicted by any of its four hypotheses, and it
is about this repository rather than about creneau: bkn's primitives are
reachable from the CLI, from the script host and from the Go packages, but the
HTTP API exposes a strict subset, and the Go packages are all internal/.
An out-of-process application therefore cannot call lock, putIfAbsent or a
two-bound range query directly — it must push that logic into a script and
invoke it. That is this repository's own thesis working as written ("most of
what a backend does is not core, it is a script"), and it is not what the
predictions assumed. The cost is real and worth stating: creneau's booking
logic is split across two languages.
- Crevisto — 100+ AI image tools, pay-per-use.
- creneau — machine booking for
fablabs and makerspaces, live at creneau.intrane.fr.
Self-serve signup, a public board per lab, and never a double booking. Runs on
storeandscriptonly; its atomic steps are bkn scripts, for the reason in its ledger.
machin-bkn is a clean-room
reimplementation in machin (MFL), built
from this repo's published contract alone — contract/, test/,
examples/ and the live HTTP surface, never the Go.
It is not an alternative to run. It is a check on whether the contract is
complete: anything it cannot reproduce is something that was only ever written
down in Go. It currently passes 191/191 of the assertions in
test/.
That check has earned its keep. Holding a second implementation to the same
suite is what surfaced that the suites are unrunnable without the fixtures the
live instance was seeded with by hand — a fresh instance scored 1/12 on
t-files for want of two namespaces, which says more about the harness than
about any implementation.