Scope: CRUD applications within what the templates cover; more complex business logic (cross-module correlated state, requirements beyond the runtime's capabilities, etc.) still needs template extension or hand-written code.
The Popular Approach
A developer sits at their desk, opens a chat window, types "build me a product management dashboard," and waits for AI to generate the entire application. This flow is clean and intuitive—almost everyone newly introduced to AI programming naturally walks this path. Describe the requirement, get the code, run it. If everything works, the story ends there; if not, ask again and iterate until satisfied. This approach has become popular because it transforms "writing code" into "making requests," compressing work that used to take weeks into just a handful of conversations. For prototype validation, one-off scripts, and small personal projects, this is nearly the optimal solution: the system scale is small, the verification burden is light, every line of generated code remains within the range of human attention, and a single manual review can confirm quality.
But the hidden cost of this path begins to show as scale increases. A complete business application often contains hundreds or thousands of lines of code, each of which could be an AI hallucination—wrong interface signatures, mismatched field types, missing edge cases. More critically, once the previous result is treated as an untrustworthy baseline, any change can drag a large swath of code back into re-verification. The typical result: the faster you develop, the more exhausting verification becomes; the more changes you make, the less controllable the risk. This tension is repeatedly felt during the long-term maintenance phase in many teams—the initially generated code works, but six months later when you want to add a field or change a page's logic, you discover that the uncertainty introduced by the change itself is higher than writing it from scratch.
So another approach is being adopted by more people: instead of letting AI generate the complete application directly, let it do only one thing—produce structured, verifiable configuration or Schema—then hand the rest to a deterministic generation layer. The Schema's scope is strictly constrained, and the output is likewise bounded—JSON field definitions, type whitelists, naming conventions. These are far easier to inspect than an entire application. Once the Schema passes, the generator produces code by fixed rules; AI then only does incremental edits, working on a smaller language surface with less contextual burden.
This divide-and-conquer strategy sounds counterintuitive—why add an indirect layer instead of doing it in one step? But it targets a structural problem of the AI coding era: it's not that generation capability is insufficient, it's that as the scale and change footprint of generated code grow, the verification burden can rise along with it. Centralizing the generation constraints in a Schema and reusing verified templates reduces the need to re-review common logic over and over—decoupling "decision" from "execution," letting AI do what it's good at (decision-making) and letting templates do what they're reliable at (execution), with a small, deterministic Schema as the interface between them.
Drawn as a pipeline: AI produces a constrained Schema → validate checks it → a deterministic generator → a complete generated application. AI only touches the input on the far left; everything downstream is reproducible mechanical execution.
Alternative Approach
Another direction is to hand-write all the code and rely on human memory and review habits to maintain quality, but this is equally unsustainable in fast-iteration scenarios.
lume-nsgm takes a third path: decoupling "decision" from "execution."
AI does only one thing: produce a structured JSON Schema. Module names, field names, types—all fall within a tightly constrained, small scope. lib/validate.lume surfaces every issue in one shot right from the start:
$ lume-core main.lume bad.json
schema validation failed (2 error(s)) in "bad.json":
2. modules[0].fields[1]: "name" must match [a-z][a-z0-9_]* (got "2bad")
fix the config and re-run.
The type whitelist cuts off illegal input; the naming convention simultaneously narrows the identifier-injection surface—payloads like "evil\"; DROP TABLE x--" that once slipped through unvalidated generators are blocked before the name is ever injected into SQL DDL or routes. One-shot validation returns all errors with precise paths, letting AI fix the Schema in one focused pass and cutting down on iteration to discover issues one by one. A field-level default is the same kind of injection surface—it is spliced verbatim into the DDL, so it is narrowed to a single-quoted literal (inner chars limited to letters, digits, underscore, space, and hyphen): 'pending' is valid, whereas fragments like x'); DROP TABLE o-- are rejected outright at the validation stage.
Once the Schema passes, the templates take over the remaining work. These details don't need AI to re-infer each time—as long as the input falls within what the templates cover, the output is deterministic and reproducible.
The intuition behind this split is simple: AI excels at decision-making within constrained spaces, and templates excel at applying deterministic rules to any input. The interface between the two is a small, stable Schema.
The cost of this split follows inevitably. Parts the templates can't cover require supplementation at the runtime layer. The early version of lume-core lacked built-in SHA256/HMAC, so the WeChat Pay v3 adaptation in scaffold/lib/pay.lume was removed entirely—it wasn't that the e-commerce capability was lacking, but that the underlying runtime was missing that symbol.
External dependencies need handling too. The payment flow can't be exercised directly against the sandbox, so scripts/mock_pay_gateway.py implements a full PayPal Express Checkout mock with the Python stdlib—OAuth2 token issuance, order creation, capture, refund—zero third-party dependencies:
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/v1/oauth2/token":
self._oauth(raw)
elif self.path == "/v2/checkout/orders":
self._orders(raw)
elif self.path.startswith("/v2/checkout/orders/") and self.path.endswith("/capture"):
oid = self.path[len("/v2/checkout/orders/"):-len("/capture")]
self._capture(raw, oid)
The mock gateway turns external dependencies into a predictable state machine, enabling make test-e2e to run the full chain on every change—order creation, redirect to approve, return capture, status persistence. No dependency on a real gateway, no reliance on manual intervention.
The applicable scope of this path is clear: CRUD-intensive projects where templates can cover most pages. Its determinism and verifiability are invaluable within scope; beyond scope, the cost is continuous patching of the core runtime, expanding template boundaries, and growing cognitive burden at the configuration layer.
Evidence
Validation doesn't report one error at a time—it runs a full pass and returns everything at once. lib/validate.lume performs a comprehensive check before generation: JSON format, non-empty modules, naming conventions, type whitelists, duplicate-name detection, empty fields, default literals—every item with a precise path, all errors returned at once with fix suggestions. Two errors, two issues at different levels—type not on the whitelist, name not starting with a letter. The validator returns every error with precise paths at once, letting AI fix them in one focused pass rather than iterating one error at a time. tests/t6.lume has 19 assertions covering this flow; on failure the exit code is 1 and the entire generation is skipped, producing no half-built project.
The naming constraint design intent goes beyond style consistency. Field names are spliced directly into SQL DDL and dynamic routes, so two risks must be distinguished here: identifier injection—field or table names spliced into DDL/routes—is limited by a strict whitelist like "[a-z][a-z0-9_]*"; whereas value injection—user-supplied values inside a query—should still be handled by parameterized queries and the like, not by name validation. Historically, payloads like "evil\"; DROP TABLE x--" were once allowed through by unvalidated generators; lume-nsgm blocks such illegal identifiers at the validate stage first. The more constrained the Schema, the fewer illegal inputs the generator has to handle.
An e-commerce Scenario Schema looks like this (example-paypal.json):
{
"db": "shop_paypal_demo",
"out": "./gen",
"modules": [
{
"name": "product",
"fields": [
{"name": "name", "type": "varchar"},
{"name": "price_cents", "type": "integer"},
{"name": "stock", "type": "integer"}
]
},
{
"name": "order",
"fields": [
{"name": "order_no", "type": "varchar"},
{"name": "amount_cents", "type": "integer"},
{"name": "product_id", "type": "integer"},
{"name": "product_name", "type": "varchar"},
{"name": "paypal_order_id", "type": "varchar"},
{"name": "capture_id", "type": "varchar"},
{"name": "status", "type": "varchar", "default": "'pending'"}
]
}
],
"pay": {
"provider": "paypal",
"secret_env": "PAYPAL_KEY",
"mode": "sandbox",
"currency": "USD",
"order_module": "order",
"order_no_field": "order_no",
"amount_field": "amount_cents",
"status_field": "status"
},
"shop": {
"product_module": "product",
"order_module": "order",
"name_field": "name",
"price_field": "price_cents",
"stock_field": "stock",
"product_field": "product_id",
"product_name_field": "product_name"
}
}
product has three columns: name, price (in cents), stock; order carries, beyond the system auto-increment id, order number, amount_cents, product ID, product-name snapshot, paypal_order_id, capture_id, and a status defaulting to 'pending'. The pay section wires the order module to PayPal (secret_env holds only the env var name—the secret value never enters config), and the shop section declares the product module and the columns the buy flow writes. Field count, type, and naming all fall within the whitelist; validated once, all downstream generation steps are based on this input.
Once the Schema passes, the generator takes over. Parameter escaping, pagination boundaries, UI conventions—these details are hardcoded in the templates. Any valid Schema input within the covered range yields consistent output. lib/model.lume handles type mapping and index filtering, lib/sql.lume handles DDL generation, lib/modlume.lume handles module generation, lib/reactgen.lume handles the frontend, and lib/scaffold.lume writes the entire directory tree to gen/. Four modules with clear division of labor, each with corresponding regression tests: t1.lume verifies model and sql generators (13 assertions), t2.lume verifies modlume and reactgen (modules and frontend), t3.lume verifies template engine variable substitution and conditional rendering, t4.lume verifies the regression behavior of sql_default().
The test suite itself is also evidence. All green on make check && make test && make test-e2e—what does that mean? It means the entire chain from Schema to DDL, from route registration to React pages, from SSR rendering to API endpoints, is reproducible. This isn't "the generator wrote it," it's "what the generator wrote has been verified."
Templates can't solve every problem; some business logic requires runtime intervention. Stock deduction is a typical example—normal purchase, sold-out rejection, and refund restocking, all three edge cases matching expectations. The deduction logic itself is a single SQL statement:
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock >= 1
Single statement, atomic execution. sql_write returns the affected row count; 0 means sold out or concurrently contested, in which case the compensation path is taken: DELETE the pending order just created, show a "Sold Out" page. Stock deduction completes atomically in a single conditional UPDATE, avoiding the typical read-modify-write race that comes from reading stock first and writing it back separately; but the failure compensation between order creation and stock deduction (rolling back after a failed deduction, or a mid-process crash) is a cross-operation consistency concern that needs its own verification of recovery behavior.
The order state machine is implemented at the application layer using varchar + route-level whitelist validation: pending → paid (payment callback idempotent), paid → refunded (only paid orders are refundable, repeated refunds are idempotent). State transitions bypass database constraints, controlled entirely by route logic, for easier observation and debugging.
The payment flow's implementation details aren't the focus here; below it serves only as a case of the generation layer's extensibility. After the admin e-commerce flows end-to-end, the next step is real payment collection. Once the crypto builtins were in place, the PayPal Express Checkout adapter could be re-landed. scaffold/lib/pay.lume adds paypal_base_url, paypal_basic_auth, paypal_fmt_cents, paypal_token, paypal_create, paypal_capture, paypal_verify_webhook—OAuth2 client_credentials fetches the Bearer, HTTP Basic credentials are encoded with the base64_encode builtin, client_secret goes through secret_env injection and never enters config or images. The entry route generates PAYPAL_CFG; the frontend takes approve_url and performs a window.location redirect; the return callback captures and updates status; idempotency guarantees that duplicate returns don't produce duplicate records.
The payment flow's e2e validation goes through the same mock: _oauth validates Authorization: Basic base64(client_id:client_secret) and issues a Bearer token; _orders accepts order creation requests, records return_url, and returns approve_url; _capture completes fund capture; _pp_refund handles refunds, returning 422 for a duplicate refund of the same capture. The external dependency is folded into a predictable state machine, which is what lets the chain run end to end on every change. To be clear, "e2e" here means end-to-end validation between the app and the local mock gateway; it is not the same as having validated every payment, refund, and error scenario against the real PayPal sandbox.
The Docker deployment layer was also adjusted accordingly: Dockerfile adds ARG NSGM_CONFIG (example-paypal.json for the payment-variant build), and docker-compose.yml injects PAYPAL_KEY and PAYPAL_RETURN_URL from the host env (when PAY_GATEWAY is unset it defaults to the code's built-in sandbox address, https://api-m.sandbox.paypal.com; override it in the env to point elsewhere), none baked into the image layer; healthcheck switches to probing GET /, which exists in both build configurations.
Looking at the four layers of evidence together, lume-nsgm's "deterministic generation" isn't a slogan—it's a grep-able evidence chain:
- Validation layer:
lib/validate.lumeblocks illegal input at the Schema stage; the 19 assertions int6.lumelock the behavior in. - Runtime layer: a stock-conditional atomic UPDATE avoids the typical stock read-modify-write race; cross-operation failure compensation still requires independent verification.
- Test layer:
mock_pay_gateway.py's state machine neutralizes external dependency uncertainty. - Regression layer: assertion counts lock the quality baseline—
test-crypto.lume31 (sha256/hmac 17 + base64 14),t6.lume16,t1.lume13.
Four layers of evidence map to four dimensions of controllability: input-controllable, execution-controllable, dependency-controllable, result-controllable. Each one can be grepped to a concrete implementation in the source code; nowhere does it rely on "hoping it works." This is also why this path holds up—not because it's smarter than direct generation, but because it leaves verifiable traces at every stage.
That said, three layers need to be distinguished—each solves a different problem, and none alone guarantees "the business result is correct" (these assertion counts only show that the corresponding behavior is covered by tests, not that uncovered scenarios are also correct):
| Layer | What it solves | What it can't guarantee alone |
|---|---|---|
| Schema validation | Input format, field types, naming constraints | Whether the business requirement is complete |
| Deterministic generation | Uniform code structure, less duplicated implementation | That the template itself has no defects |
| Automated testing | Checking the covered, expected behaviors | That uncovered scenarios are also correct |
So the real claim isn't "make AI never err," but "make errors easier to surface, locate, and fix": collapsing open-ended code generation into a smaller input space, which lowers verification cost.
Boundaries
A team used the product and order modules from example-paypal.json to build a working e-commerce admin dashboard. Stock deduction went through the guarding UPDATE, the payment flow was configured with a mock gateway, make test-e2e passed green, and the deliverables were clean. This is the highlight of this method—and also where its boundaries begin to emerge. Not that it isn't good enough, but that it's too "deterministic."
When CRUD Is No Longer Just CRUD
The underlying assumption of this method is: business can be decomposed into modules and fields, modules are related through ref types, and state flows via strings. This assumption hits the mark for backend management systems, content management platforms, and internal tools. For frontends with dense interactivity or business models that exceed the table-form paradigm, the assumption no longer holds.
A typical wall-collision moment occurs with the shopping cart. The template layer currently lacks the ability to express cross-module correlated state: a cart would need cart / cart_item tables with only the cart_id kept in session, and the templates can't generate that structure. The ref type can link orders to products, but a shopping cart is a mid-lifecycle transient state: add-to-cart, quantity modification, expiry cleanup, multi-device merge. These behaviors can't be described in a few lines of JSON fields; they must be hand-written outside the templates.
The enum type also exposes a boundary. To narrow enum values during validation while keeping DDL as varchar and rendering forms/lists from values, the rendering logic has to be written into the templates—the template engine {{#each}} and {{#if}} can do conditional branching, but every new enum value then requires a template change rather than a config change. The coupling between configuration and logic grows heavier, and the claim "the more precise the Schema, the easier AI's decision-making" loses some force here.
When Runtime Gaps Require Core-Level Patches
The generation layer of lume-nsgm is deterministic, but its determinism rests on the builtins provided by lume-core. Whatever is missing must be filled in—and the engineering effort to patch core is on a different order of magnitude than patching a configuration file.
The WeChat Pay v3 adaptation removal is the clearest example. It wasn't that the e-commerce capability was insufficient; the SHA256-RSA symbol simply wasn't in core.
This kind of "patch core" is not an isolated incident—it's a structural cost. When business requirements exceed what templates can express, you either extend the templates (increasing complexity) or fill in the runtime (expanding scope). Neither approach is wrong, but both require awareness: the deterministic generation layer is not a self-contained system; it depends on the underlying runtime to continuously provide support. Getting the core runtime to meet requirements is itself an engineering investment.
When the Schema Itself Becomes the Bottleneck
The design philosophy of lib/validate.lume is one-shot validation—JSON format, non-empty modules, naming conventions, type whitelists, duplicate-name detection, empty-field checks—all errors returned at once with precise paths. This is highly efficient for small-scale Schema: errors come back at once with precise paths, letting AI fix them in one pass, and the 19 assertions in tests/t6.lume cover this flow.
But when the Schema swells to dozens of modules and hundreds of fields, a one-shot validation receipt becomes long. Every error carries a fix suggestion—AI can handle it, but the human cost of reading and locating issues rises. More importantly, the Schema now carries the business intent that developers used to express directly in code—field semantics, relationship meaning, state transition rules—all decomposed into JSON structure descriptions. The more precise the Schema, the easier AI's decision-making; the more complex the Schema, the higher the human cognitive cost of understanding it.
In this situation, an interface that was once clear (AI produces Schema, templates produce code) begins to blur: the Schema itself needs to be understood and maintained by humans, and it is no longer "small, controllable, and verifiable."
When to Fall Back to the Popular Approach
Not every project is worth building a generation layer for.
If the project only needs a one-off prototype validation, or the feature set is small and changes infrequently, letting AI generate the entire application directly remains the path of least resistance. Verification burden is light, the toolchain is simple, and there's no need to maintain agreements between templates and Schema.
If the project requires deep custom interactivity, frequent business model iteration, or heavily depends on certain runtime capabilities whose fill-in cost exceeds the cost of regenerating, hand-writing code or using AI to generate whole blocks of logic may be the better choice. The value of deterministic generation lies in "verify once, reuse repeatedly"—but if the foundation for reuse itself is unstable, that value doesn't hold.
This method targets the problem of a rising verification burden as generation scale grows, decoupling "decision" from "execution," letting AI decide and templates execute. But it doesn't eliminate cost—it only shifts it from the code generation phase to architecture design and low-level gap-filling.
Within the scope where CRUD is dense, templates can cover most pages, and runtime gaps are controllable, this path's determinism and verifiability are invaluable. Beyond this scope, the cost is continuous patching of the core runtime, ever-expanding template boundaries, and the cognitive burden of the Schema itself.
Treating a counterintuitive approach as absolute truth is as dangerous as treating the popular approach as the only valid path. Tool selection depends on the shape of the problem, not the advanced nature of the tool.
Placing the two paths side by side makes it clearer what each is suited for:
| Dimension | Direct AI generation | lume-nsgm |
|---|---|---|
| Primary generation input | Natural-language requirement | Structured Schema |
| Common CRUD implementation | Regenerated or modified each time | Reuse verified templates |
| Input error checking | Relies on prompts and tests | Schema validation up front |
| Custom business logic | More flexible | Requires extending templates or hand-writing |
(This table contrasts the design trade-offs; it is not a quantified performance conclusion—the actual verification cost of either path must be measured per project and team.)
Quick Start: From One Schema to a Running App
You don't have to take the argument on faith—run the minimal flow yourself (prerequisite: lume-core installed, see the repo README).
① Input — a three-field Schema:
{
"db": "shop_demo",
"modules": [
{
"name": "product",
"fields": [
{ "name": "id", "type": "integer", "primary": true, "auto": true, "system": true },
{ "name": "name", "type": "varchar", "required": true, "searchable": true },
{ "name": "price_cents", "type": "integer", "required": true }
]
}
]
}
② Generate — one command produces the whole project:
$ lume-core main.lume quick.json out=/tmp/nsgm-demo
generated: product -> /tmp/nsgm-demo
done: 1 modules
/tmp/nsgm-demo/
├── app.lume # entry: server + route registration + run()
├── modules/product.lume # data-driven module: DDL + routes + SSR list/form
├── sql/product.sql # CREATE TABLE DDL
├── lib/ # db / ui / pay runtime helpers
├── web/ # Vite + React + TS frontend (one page per module)
└── www/ # SSR static styles and fallback JS
③ Verify — the generator assertion suite and the end-to-end probe:
$ make test # t1–t8: generators / templates / validation / payment assertions
$ make test-e2e # boots the generated project + curls SSR/CRUD/search/pagination/dynamic routes
④ View — start the server and open a browser:
$ make serve # → http://127.0.0.1:8081 (SSR list/form + REST API + React pages)
If the Schema contains an illegal field name (e.g. 2bad) or an unknown type (e.g. uuid), generation is rejected in one shot at the validation stage with precise paths—no half-built project—exactly like the error example above.
Source Code Navigation
Source is open, default branch main: https://github.com/erishen/lume-nsgm
scripts/mock_pay_gateway.py— mock PayPal gatewayscaffold/lib/pay.lume— payment adapterlib/validate.lume— Schema validationtests/t6.lume— validation-flow assertions
Why does lume-nsgm choose to split AI generation from code generation, rather than letting AI write the entire project directly?
With direct generation, as the scale and change footprint of the code grow, the verification burden may rise along with it—regeneration typically means re-reviewing a large amount of code. After splitting, AI only produces a constrained JSON Schema, and the templates handle deterministic execution. Once the Schema passes, the generator produces code by fixed rules; AI then only does incremental edits, working on a smaller language surface.
How does the Schema ensure input safety and prevent injection attacks?
lib/validate.lume performs a full check before generation, including type whitelists, naming conventions, duplicate-name detection, and empty-field checks. Two risks must be distinguished: identifiers (field/table names) are spliced directly into SQL DDL and dynamic routes and are limited by the [a-z][a-z0-9_]* whitelist—payloads like "evil\"; DROP TABLE x--" are blocked before the name is ever inserted; whereas values supplied by users inside a query should still be handled by parameterized queries and the like, not by name validation. A field-level default likewise sits on the identifier side of the injection surface and is restricted to a single-quoted literal. 19 assertions in tests/t6.lume cover the validation flow; on failure the exit code is 1 and the entire generation is skipped.
What kind of projects is this approach suitable for, and what scenarios is it not suitable for?
It is suitable for CRUD-intensive projects where templates can cover most pages—in that scope, determinism and verifiability are invaluable. It is not suitable for scenarios that heavily depend on custom runtime capabilities (e.g., the early lack of SHA256/HMAC causing the WeChat Pay v3 adaptation to be removed) or where the business model frequently exceeds predefined types (e.g., custom JOIN queries, nested enum rendering, cross-module correlated-state shopping carts).
How is the payment flow verified in e2e tests—does it require a real gateway?
No. scripts/mock_pay_gateway.py implements a full PayPal Express Checkout mock using Python stdlib, including OAuth2 token issuance, order creation, capture, and refund, with zero third-party dependencies. It turns external dependencies into a predictable state machine, enabling make test-e2e to run the chain on every change—order creation, redirect to approve, return capture, status persistence. To be clear, “e2e” here means end-to-end validation between the app and the local mock gateway; it is not the same as having validated every payment, refund, and error scenario against the real PayPal sandbox.
What problems arise when the Schema swells to dozens of modules and hundreds of fields?
The maintenance burden of validation itself increases—the 19 assertions in t6.lume must be rewritten every time a new type or constraint is added. Meanwhile, the Schema now carries business intent that developers used to express directly in code (field semantics, relationship meaning, state transition rules). The more precise the Schema, the easier AI’s decision-making; but the more complex it becomes, the higher the human cognitive cost of understanding it. The cognitive burden shifts from the code layer to the configuration layer.
Leave a reply