An orchestration language for subject matter experts, embedded in Java.
BUBAS lets the person who understands the business write the business process, while developers keep the algorithms, the domain objects and the infrastructure in Java. It is deliberately small: no user-defined functions, no data structures, no objects, one global scope. Everything a script can do beyond sequencing, deciding and looping comes from a vocabulary that Java developers define for it.
PROGRAM ApproveOrder(orderId INTEGER, limit DECIMAL) RETURNS BOOLEAN
DECLARE purchase Order
DECLARE total DECIMAL
DECLARE taxRate DECIMAL FINAL = 0.07
purchase = LOAD_ORDER(orderId)
IF NOT ORDER_WAS_FOUND(purchase) THEN
LOG_EVENT "ERROR", "no such order: " + orderId
RETURN FALSE
END IF
total = ORDER_TOTAL(purchase) * (1.0 + taxRate)
IF total > limit THEN
LOG_EVENT "INFO", "over limit: " + total
RETURN FALSE
END IF
LOG_EVENT "INFO", "approved, total " + total
RETURN TRUE
END.LOAD_ORDER, ORDER_WAS_FOUND, ORDER_TOTAL and LOG_EVENT are not part of BUBAS. They are
Java classes the host application chose to expose. Neither is Order — it is a Java type the
script may hold and pass but never look inside.
BUBAS is not BASIC. The name is a nod, not a lineage. There is no LET, no line numbers, no
GOTO, no user-defined subroutines — and no general-purpose language hiding under the syntax.
What survives is the one thing BASIC got right: a reader who is not a hard-core programmer can
follow it.
Typed, and strict about it. Every type error, every variable read before it holds a value, every unreachable statement and every unused variable is reported before the script runs. A business process that fails halfway through is expensive; failing at compile time is not.
Extensible by the embedder, not by the script. A Java developer registers functions and
statement patterns. The script author sees a vocabulary shaped for their domain — VALIDATE order AGAINST rules, not a general-purpose language they must first learn.
Built for generated code. The vocabulary is fixed at startup, the grammar is line-based, and the analyser rejects the mistakes an LLM actually makes. A generated script either compiles or comes back with a precise diagnostic naming the line.
Opaque by design. Domain objects cross into BUBAS as registered opaque types. A script can
hold one, pass it and store it in an array, but never inspect it. Anything requiring
interpretation — comparing two, rendering one as text, testing one for absence — is a function the
embedder supplies, named for the domain. There is no order.customer.account.balance in BUBAS,
only CUSTOMER_OF(purchase) if the embedder decided that operation should exist.
BUBAS itself stays the same size for every embedder. What changes is what you register. An order-processing application might expose:
LOAD_ORDER ORDER_TOTAL CUSTOMER_OF CUSTOMER_RISK
APPROVE REJECT REQUEST_APPROVAL
an insurance system:
LOAD_CLAIM POLICY_OF CLAIM_AMOUNT CALCULATE_EXPOSURE
APPROVE_CLAIM REQUEST_DOCUMENTS
and the resulting script reads as that domain rather than as a program that happens to be about it:
PROGRAM RouteClaim(claimId INTEGER) RETURNS BOOLEAN
DECLARE filing Claim
DECLARE exposure DECIMAL
filing = LOAD_CLAIM(claimId)
exposure = CALCULATE_EXPOSURE(filing)
IF exposure > 50000.0 THEN
REQUEST_DOCUMENTS filing, "loss adjuster report"
RETURN FALSE
END IF
APPROVE_CLAIM filing
RETURN TRUE
END.You get a domain-specific language without writing one. The grammar, the type system, the analyser and the diagnostics are BUBAS's; only the words are yours.
Note filing rather than claim: a registered type name is reserved, so a variable may not be
named after its type. It is the same rule that stops userId and UserID from being two
variables, and it surprises people, so the diagnostic names the type explicitly.
A BUBAS script can name exactly three things: its own variables, the language's own operators and
control flow, and the vocabulary the embedder registered. There is no import, no reflection, no
eval, no way to name a Java class, and no filesystem, process or network primitive built into
the language. If the host did not expose an operation, a script cannot express it — the program
does not fail at run time, it fails to compile, because the name means nothing.
That makes the registered vocabulary an explicit boundary, which is worth being precise about:
- It bounds what a script can name, not what your code can do. A function you expose can do
anything Java can. The boundary is only as narrow as the operations you decide to register, and
RUN_SHELL_COMMANDis a perfectly registrable function. - Registration is opt-in. Discovery mechanisms find whatever is on the classpath; the builder decides what gets registered, so an unrelated jar cannot widen the vocabulary — or reserve a word an existing script uses as a variable.
- It is not a resource sandbox. BUBAS has no execution time or memory limits.
DO WHILE TRUEcompiles and runs forever. Untrusted input needs the same containment any other untrusted workload needs.
Within those limits it does answer one specific question well: how much of your application are
you willing to let generated code program? A script whose entire vocabulary is
LOAD_ORDER, ORDER_TOTAL, APPROVE, REQUEST_APPROVAL cannot be talked into doing anything else,
and the failure mode for trying is a compile error rather than a judgement call at run time.
A host application builds the language it wants to expose, seals it, and compiles against it:
BubasLanguage lang = BubasLanguage.builder()
.defineOpaqueType("Order", Order.class)
.defineFunction("LOAD_ORDER", LoadOrder.class)
.defineFunction("ORDER_TOTAL", OrderTotal.class)
.defineStatement("APPROVE {expression/Order:target}", ApproveOrder.class)
.seal();
BubasProgram prog = lang.compile(source);
boolean approved = Interpreter.of(prog)
.argument("orderId", 42L)
.argument("limit", new BigDecimal("1000.00"))
.registerService(OrderService.class, orderService)
.run()
.asBoolean();public final class LoadOrder {
public Order call(Context ctx, long orderId) {
return ctx.service(OrderService.class).load(orderId);
}
}The signature is read off the Java method: LOAD_ORDER(orderId INTEGER) -> Order. Nothing is
declared twice, and nothing names a method in a string — a class reference is what an IDE renames
and the compiler checks.
A statement is the same idea with a shape. The pattern APPROVE {expression/Order:target} both
defines the syntax and reserves APPROVE, and the handler's parameters must line up with the
placeholders — registering "APPROVE" for a handler that takes an argument is rejected when the
language is sealed, not when a script first runs.
Three objects, three lifetimes. A Language is sealed once and shared by everything. A
Program is compiled once and reused. An Interpreter is cheap, single-use, and carries
whatever varies per run — arguments, services, MathContext, logger.
Services follow that split. Register one on the builder and every interpreter of that language shares it, which is what a singleton collaborator wants; register one on the interpreter and it belongs to that run, and overrides the language's for the same type. A shared one is used by concurrent runs, so it has to be thread-safe; anything per-request belongs on the run.
One class is one function or one command. That is what lets the signature be derived rather than
declared, and it means compiled output can call the implementation directly instead of dispatching
by string key through a registry that would have to be rebuilt first. The runtime constructs the
class itself, with no arguments, so it cannot capture the embedder's objects — every dependency
arrives through ctx.service(...), which makes the service registry the only route from a shared,
sealed language to per-run state.
Arrays cross the boundary as native Java arrays — long[], BigDecimal[], Order[] — passed as
the interpreter's own storage, so Arrays.sort works and an in-place reorder is visible to the
script.
source ──▶ lexer ──▶ pattern matcher ──▶ parser ──▶ symbol table
│
interpreter ◀── core tree ◀── flow analysis + lowering
| Module | Contents |
|---|---|
bubas-api |
types, values, the embedder-facing interfaces |
bubas-lexer |
tokens, logical lines, continuation and comment handling |
bubas-analyser |
BubasLanguage, BubasProgram, patterns, parser, analysis, lowering |
bubas-runtime |
Interpreter and dispatch |
bubas-support |
the standard statements and functions |
bubas-test |
executable .bu programs and the runner that checks them |
bubas-bunit |
the mocking framework for testing BUBAS programs |
bubas-export |
describes a sealed language for a generator, or a person |
bubas-bunit-matchers |
argument lists and matchers, for any test vocabulary |
bubas-bunit-commands |
the statements a BUBAS unit test is written with |
bubas-bunit-standard |
the assembled test framework, which is what you depend on |
Lowering produces a typed core tree that the interpreter executes. It exists so that a future code generator consumes the same tree rather than re-deriving semantics from the AST — divergence between backends is the characteristic bug of that design, and a shared IR is what prevents it.
A BUBAS program is tested in BUBAS. The test replaces the host's vocabulary with mocks and asserts on what the program did with it — the decisions and the sequencing, which is all a BUBAS program has.
PROGRAM OverLimitIsRejected
"LOAD_ORDER" WITH ARGS(42) RETURNS "o1"
"ORDER_TOTAL" WITH ARGS("o1") RETURNS 1500.00
"APPROVE _" IS MOCKED
ARGUMENT "orderId" IS 42
ARGUMENT "limit" IS 1000.00
RUN
RESULT IS FALSE
"APPROVE _" WAS NOT CALLED
END.An expectation can say what matters about a call without pinning what does not:
"LOG_EVENT _, _" WAS CALLED WITH ARGS("INFO", CONTAINS("over limit"))var results = BunitSuite.of(orderLanguage, subjectSource).runAll(tests);
System.out.println(BunitSuite.report(results));"o1" is a token: a stand-in for an Order that carries identity and nothing else. A script
can hold an opaque value and pass it but never look inside, so it cannot tell a token from the real
thing — which is why a test never has to construct one. The opacity that exists for encapsulation
buys total mockability.
Mocking happens at dispatch, so the program under test is compiled against the real language. There is no parallel vocabulary to drift out of step, and running the same test without the mocks installed runs it against the real implementations.
Before a test executes, its mocks are checked: a mocked command that would leave a variable
unwritten, an argument for a parameter the program does not take, a mock answering the wrong type
or declared for the wrong number of arguments — each is reported with a line, in the test, before
the program runs. See BUNIT.md for why it is built this way.
A signature says what a function takes. It does not say what it means, and meaning is what an LLM needs before it can write anything worth compiling.
@BubasDescription("Finds an order by the identifier the customer was given. Fails if none.")
public final class LoadOrder {
public Order call(Context ctx, long orderId) { … }
}Files.writeString(prompt, VocabularyExport.of(language).asMarkdown());which yields the language, described:
### LOAD_ORDER(orderId INTEGER) -> Order
Finds an order by the identifier the customer was given. Fails if none.
### COUNT ORDERS INTO _ FOR _
COUNT ORDERS INTO {new > identifier/INTEGER:total > initialized} FOR {expression/STRING:region}
Counts the orders of a region, leaving the number in the variable named.
Leaves a value in: totalShape is derived, meaning is written, and the two never overlap — a description must not restate anything the export already knows. Prose repeating a signature adds nothing and will one day contradict it, and the prose is always the half that is wrong.
No Java appears in an export: no class names, no packages. Whoever reads it is going to write BUBAS, and the Java behind a function matters to them the way a bicycle matters to a fish. It is also why an export can be handed to an outside model without handing over an inventory of your internals.
Descriptions are demanded by the export and by nothing else. A language without them seals, compiles and runs perfectly — it simply cannot be exported, because an export with holes reads like documentation. Nobody who doesn't export pays for them; nobody who does can forget them.
A description on the wrong side of a rewrite is worse than none, so @BubasReviewed records a
checksum of the described class's public surface. Change the class and the export refuses,
prints what the surface is now, and names the value to write once the description has been re-read.
The export and not seal(), for the reason descriptions are demanded there too. A checksum fires on
any change to a described class, most of which are ordinary development having nothing to do with
documentation — failing to seal would break startup and every test of an application that generates
none, and the rational answer to that is to delete the annotation. A check that fires too widely
destroys the thing it protects.
It catches a change of shape; a function whose behaviour changed and whose signature did not moves no checksum, and nothing pretends otherwise.
git clone https://github.com/verhas/bubas.git
cd bubas
mvn testThe most readable place to see what the language does today is the script corpus:
bubas-test/src/test/resources/scripts
Each .bu file is a whole program that declares its own expectation in a header comment — that it
should not compile, that it should fail at run time with a given message and line, or that it
should run to completion. The ok/ scripts check their own results with ASSERT, so a script that
finishes has verified itself rather than merely not crashed.
Implemented and executable. The front end, analyser and interpreter all work, and BUBAS
programs can be unit tested in BUBAS. mvn test runs over six hundred tests, more than a hundred of
them whole BUBAS programs — the language's own corpus and BUNIT's.
| Phase | Scope | State |
|---|---|---|
| 1 | Lexer, parser, symbol table, flow analysis, lowering, interpreter | done |
| 2 | Pattern system, custom statements, standard statement and function set | done |
| 3 | Code generation to Java, plus the interpreter/codegen conformance suite | not started |
| 4 | Vocabulary export for LLM code generation | export done, prompt tooling not started |
| 5 | Diagnostics, profiling, hardening | ongoing |
| — | BUNIT: unit testing BUBAS programs in BUBAS | done, still growing |
Not built yet, beyond the phase table: extension discovery. SPEC.md describes a builder API for
registering functions and commands found on the classpath by ServiceLoader; today registration is
explicit, one defineFunction or defineStatement at a time. The provider() static-factory
convention implementations may use is already supported, so classes written for discovery work
through explicit registration unchanged.
The language questions that remain deliberately open are listed in SPEC.md §16.
SPEC.md— the language definition and Java API reference; normativeBUNIT.md— how BUBAS programs are unit tested, and why that designCLAUDE.md— conventions for working in this repository
Issues, experiments, criticism and pull requests are welcome. The most useful reports are the ones that test the design rather than the code:
- programs that should be valid but are rejected
- programs that should be rejected but are accepted
- diagnostics that do not explain themselves
- real embedding attempts, especially the ones where the capability model got in the way
- BUBAS generated by an LLM, and what it got wrong
If you embed BUBAS in something real, open an issue and say what happened.
Releases are cut locally, on purpose. The signing key Maven Central knows exists on one machine and a local backup — not in a cloud, not in a secrets vault — so nothing in CI is able to sign, and nothing in CI is trusted with the ability.
mvn -P release deployThe release profile attaches sources and javadoc, signs everything with GPG, and publishes
through the Central portal. It needs two things on the machine that runs it: the signing key
available to gpg, and credentials for the central server id in ~/.m2/settings.xml.
What CI can do it does. Every push runs
mvn -P release verify -Dgpg.skip=truewhich is the release build with only the signing removed — so a release that would have failed on a malformed javadoc comment, a module that packages badly, or a missing sources jar fails on the push that caused it rather than halfway through a deploy.
Version numbers live in one place: the revision property in the parent pom.xml. Every module
inherits it, and flatten-maven-plugin resolves it away before anything is installed or published,
because a pom that still says ${revision} cannot be resolved by anyone who lacks the parent.
Apache License 2.0 — see LICENSE.
Copyright 2026 Peter Verhas