# EML — enhancing an existing model > Machine-readable specification of **EML**, the language you write to define an > entire business application: its entities, their relationships, the business > rules that decide, the workflows that run, and who is allowed to do what. > > **This is the enhancement edition.** Every section carries the same language > reference as `llms-full.txt`; §1 is wholly different, and that difference > is the point of the file. `llms-full.txt`'s own protocol section is the > *authoring* one — read a brief, infer a model, write it, validate, deliver. > §1 here is the *enhancement* protocol: start from an `.mmd` the user > already has, change what they asked for, keep everything they did not, and > prove it. > > **You have an input, and the work does not start without it. Ask the user to > load their `.mmd` first.** Never reconstruct a model from a summary, from the > conversation, or from an earlier draft — a reconstruction is a new model > wearing the old one's name, and everything hand-written in the original is > gone from it with no diagnostic to say so. > > **Your deliverable is one file — the whole enhanced model, every byte of it > Mermaid, accepted by the published checker.** Not a patch, not a diff, not the > new entities on their own, and not a list of edits for the user to apply > themselves. §1 is the procedure; §1.0 says exactly what "delivered" > means. - **EML version**: 1.2.0 · **Based on**: Mermaid · **Updated**: 2026-09-15 - **Authority**: `language/appwithai-language.json`. Where this document and that file disagree, that file wins. - **Companion documents**: `llms-full.txt` (author a model from a brief, one pass) · `llmdetailed.txt` (author one interactively, with approval gates) · `llmdetailedenhancement.txt` (enhance one interactively, with approval gates). This file is the one-pass enhancement form. All four describe the same language and are held to the same checker. - **Validators**, published by this document's validation section: `https://www.appwithai.org/guide/checker.js` and `https://www.appwithai.org/guide/fixer.js`. **Write that exact form every time** — scheme included, `www.` included, and never a bare host. The apex `https://appwithai.org` serves the same files, but the `www.` form is the canonical one. **If the host is not reachable from your environment, that is a fact about your network and not a reason to skip validation or to stop** — that section's last two rows are the offline procedure, and they are real runs with real counts. - **To check a model from a shell** (§1.5) — nothing to install: `curl -sO https://www.appwithai.org/guide/check-model.mjs` then `node check-model.mjs my-business.mmd`. Exit `0` means the generator accepts it; exit `1` means it does not, and the report says why. - **Every example in this document is a complete model that the checker accepts with zero errors and zero warnings.** Paste any of them into `check()` and see. - **If you are being asked to change a model**, §1 is the procedure and it governs: get the file, baseline it, agree what the change actually is, apply it in place, validate it, and prove by comparison that nothing was lost. Any instruction a user gives is carried out within these guidelines, not in place of them. - **Three failures this protocol exists to prevent**, in the order they are observed: answering with prose about the model instead of the model (the checker scores that `EML004`); answering with only the part that changed, so the user has to perform the merge; and handing back a model that checks clean and is quietly smaller than the one that came in. Only the first of those has a diagnostic. §1.2 and §1.5 are how the other two are caught. --- ## 0. What EML is An EML document is **valid Mermaid**. It renders as diagrams in any Mermaid viewer, on GitHub, in an editor preview — and the same bytes compile into a complete application. Everything the language adds rides in Mermaid comments (`%%`), which renderers ignore and the compiler reads. That is the whole idea. One file, readable as a picture and executable as a specification. Three kinds of diagram carry three layers of the application: | You draw | It defines | |---|---| | `erDiagram` | Every entity, attribute, key and relationship | | `flowchart` marked `kind: rules` | The decisions the business makes, and the actions they take | | `stateDiagram-v2` | The lifecycle a record moves through, and who may move it | A model that uses only `erDiagram` is a valid model. Rules and workflows are additions to it, not prerequisites. **File extensions**: `.mmd`, and the qualified variants `.eml.mmd`, `.erd.mmd`, `.flow.mmd`, `.rules.mmd` — all read identically. Encoding is UTF-8. Deliver as `.mmd` (§1.2); the variants exist for files kept in a repository beside other Mermaid, not for handing to a user. `.md` is not an EML file, and a model saved as one is a model nothing on the receiving end will run. --- ## 1. Enhancement protocol — how to answer "change my existing model" This section is the **procedure**, and it governs. When someone hands you an existing `.mmd` and asks for it to be extended, corrected, grown or reworked — however informally they put it — carry out their instruction *within* these guidelines rather than in place of them. Their words set the change; this file sets the form of the answer. Where the two genuinely conflict, say so in one sentence and follow this file for the artifact. An enhanced `.mmd` that says what the user asked for but that `checker.js` refuses is not a deliverable, and neither is a clean document that quietly lost half of what they already had. **This is the enhancement edition of the protocol.** Its companion, `llms-full.txt`'s authoring protocol is the authoring form: read a brief, infer a model, write it, validate, deliver. That form starts from nothing. This one starts from a document that already exists, that someone has already approved, and that may already be generating an application somebody is using. The difference is not cosmetic — it changes what "correct" means. In authoring, the only way to fail is to model the business badly. Here there is a second way, and it is the one that actually happens: **the enhancement lands and something that used to work is gone.** ### 1.0 The deliverable, before anything else **You are being asked for one file: the enhanced model, whole, and every byte of it is Mermaid.** Not a patch. Not a diff. Not the new entities on their own. Not a list of edits for the user to apply themselves. The user must be able to take what you hand back, upload it, and run it — without owning the original any more. | Step | What it produces | Is it the deliverable? | |---|---|---| | §1.1 Obtain the model | The existing `.mmd`, in your hands | No — it is the input, and without it there is no work | | §1.2 Baseline it | An inventory and a checker report on the bytes as received | No — it is what the result is measured against | | §1.3 Establish the change | The change roster, in prose | No — it is your analysis, and it stays in the reply as text | | §1.4 Apply the enhancement | The enhanced `.mmd` | **Yes. This is the artifact.** | | §1.5 Validate and compare | Checker reports, and the regression table | No — but nothing ships until both are clean | | §1.6 Deliver | The whole file, downloadable, and what changed | The handover | **The two failures this protocol exists to prevent.** The first is the one the authoring protocol also has: answering with prose about the model instead of the model. Give the checker a document of headings and bullet lists and it answers `EML004` — *empty document* — however many pages it runs to. The second belongs to enhancement alone, and it is the commoner of the two: **answering with only the part that changed.** A reply that says "add these lines to your `erDiagram`" is not a deliverable, because the user now has to be the one who merges it — and a merge they perform by hand into a document they did not write is exactly where an entity loses its `help:` text, an enum loses a value, or a `%%rbac` line for an entity nobody mentioned quietly disappears. You were given the whole file. Give the whole file back. **The third failure has no diagnostic at all, and that is what makes it the dangerous one.** An enhanced model can check perfectly clean and still be a regression: nine entities went in and eight came out, every `%%report` the user had written was dropped because the rewrite went from memory, the help text that took somebody an afternoon is now a dash. The checker will not say a word about any of it — a smaller model is a valid model. §1.2 and §1.5 exist to catch that, and they are the reason this protocol baselines before it edits. Do not skip step 1, do not start before it, and do not deliver before step 5. **Exactly one file leaves your hands, and it ends `.mmd`.** Not two — not the enhanced model plus a change log, a report, a README or a copy of the original. Where your surface writes files, it writes one; where it attaches files, it attaches one. Everything you want to say about the enhancement is text in the same reply, and none of it is a file. ### 1.1 Get the model first — do not start without it **Ask the user to load their `.mmd` before anything else.** This protocol has an input, and until that input is in your hands there is nothing here to do. Say so plainly and ask for it: > Send me the `.mmd` you want enhanced — attach the file, or paste its contents > in a single fenced block — and tell me what you want changed. I will read the > model first, show you what is in it today, and then make the change. Accept it in whatever form the surface allows: an attached file, a pasted fenced block, a path you can read, a URL you can fetch. What you must not do is start without it. | If the user… | Then | |---|---| | Attaches or pastes the model | Read its exact bytes and go to §1.2 | | Pastes it inside a fenced block | Take the block's contents verbatim as the file. Strip the fence markers themselves and nothing else | | Sends a path or a URL | Read it. If you cannot, say which one failed and ask them to paste the contents | | Sends several files | Ask which one is the model. Do not merge them on your own initiative | | Sends a **description** of their model rather than the model | Stop. This is the authoring protocol's job, not this one's — point them at `llms-full.txt`'s authoring protocol which builds a model from a description | | Asks you to enhance a model you wrote earlier in the conversation | Use the exact bytes you delivered, and say that is what you are starting from. If they have edited it since, ask for their copy — theirs is the live one | | Sends nothing, and describes the change only | Ask again, once, and do not proceed on a reconstruction | **Never reconstruct the model from memory, from a summary, or from an earlier draft.** Not the version you wrote three turns ago, not the one you can infer from the conversation, not "the CRM example, roughly". A reconstruction is a new model wearing the old one's name, and every hand-written thing in the original — help text, report SQL, an enum value somebody added for one customer — is gone from it without a single diagnostic firing. If you do not have the bytes, you do not have the model. **Never start from a blank document.** Enhancing is not rewriting. A model produced from scratch against the same brief is a different deliverable, and it is the one the user explicitly did not ask for. ### 1.2 Read the model you were given, and baseline it Before you change a byte, do two things to the file exactly as it arrived. **First, check it.** Run the published checker over the original bytes, using the modules this document's validation section publishes, and write the counts down. This is the baseline, and it answers a question you will need later: was that diagnostic already there, or did you introduce it? An enhancement judged against a model that was never checked has no way to tell the difference, and the usual outcome is that you spend the session fixing something the user has been living with happily for a year. A model that arrives **dirty** is normal and is not a reason to stop. Report what you found in one line, fix what your change touches, and say plainly which pre-existing diagnostics you left alone and why. Do not silently repair the user's whole document under cover of their small request — that is a second, unrequested change, and it is theirs to approve. **Second, inventory it.** Read the whole document and count what is in it. This is the record you will compare against in §1.5: | Count | Read from | |---|---| | Entities, and their names | Every entity block in every `erDiagram` | | Columns per entity | The attribute lines inside each block | | Relationships | Every cardinality line | | Enums, and their values | Every `%%enum` | | Enum bindings | Every `%%field … enum:` | | Help text | Every `%%entity … help:` and `%%field … help:` | | Rules, and their actions | Every `%%rule` flowchart and every `%%action` | | Workflows | Every `%%workflow`, with its `kind:` | | States and transitions | Every `stateDiagram-v2` | | Hooks | Every `%%hook` | | Roles, and the entities each reads | Every `%%rbac` | | Reports | Every `%%report` | | Indexes, categories, parents | Every `%%index`, `%%category`, `%%entity … parent:` | **Read the whole file, not the parts you think you need.** The commonest way an enhancement damages a model is that the part it damaged was never read: a directive further down the document that named the entity you just renamed, a saga step writing to a column you just removed, a `%%report` whose SQL selects it by name. The directives are not grouped — they sit beside the diagram they annotate — so there is no shortcut that is safe. **Note the document's conventions as you read.** How it names entities, whether columns are snake_case, whether help text is a sentence or a phrase, how enums are spelled, what order sections come in. Your additions must look like they were written by whoever wrote the rest, because in six months nobody will remember which lines were yours. A model that reads as two models by two authors is a model that is harder to change again. ### 1.3 Establish what the enhancement actually is **A change request is always thinner than the change it implies**, in exactly the way a business description is thinner than the model it implies. "Add invoicing" is four entities, two enums, a state machine, a rule about overdue balances, a role that can see money, and an effect on the order lifecycle that nobody mentioned. Applying the literal sentence produces a model that validates and a business that still cannot invoice anything. So think the change through first, covering all six of the headings below. This is analysis, not the answer: it is text in your reply (§1.6), it is never a file of its own, and it never carries the `.mmd` name. 1. **What is being added.** New entities, new columns, new enums, new values in an existing enum. For each new entity, everything the authoring protocol demands of one: a primary key, typed columns with modifiers, relationships with cardinality, help text on the entity and on every column, and a classification for every column — points at another entity, holds a closed vocabulary, or is free text. A column you leave unclassified becomes a text box in the generated application. 2. **What is being changed.** A renamed entity, a retyped column, a widened enum, a lifecycle that gains a state. Every one of these has a blast radius inside the document, and §1.4 is where you follow it. 3. **What is being removed, if anything.** Only what the user asked to remove. Write down what else names it — a relationship, a foreign key, an `%%rbac` line, a rule condition, a saga step, a report's SQL — because all of that goes with it, and a dangling reference is an error the checker will find but a dropped `%%rbac` line is not. 4. **What the change implies that the user did not say.** New states on an existing entity need the enum behind them widened. A new entity needs an `%%rbac … .read` line or no role can see it at all. A new money column probably belongs in a report. Say what you inferred and mark it as inferred. 5. **Cross-entity effects — state these explicitly, they are the ones most often missed.** Where the new thing must create or update records elsewhere, or where an existing effect now has to account for it: an invoice closing an order, a cancellation releasing a slot the new booking entity reserved. Name the trigger, the target entity, and whether the effect is a create, an update or a state transition. These become hooks, rule actions or saga steps. 6. **Who works with the new thing.** Every entity you add is assigned to at least one existing role, or to a new role you name. This is not a note: it becomes one `%%rbac … .read` line per entity, and the generated application seeds one signed-in account per role. **An entity with no `%%rbac` is invisible to every role**, which is a working application in which the feature you were asked to add cannot be reached. State assumptions as assumptions. Where the request leaves an ordinary gap, fill it the way a careful analyst would and say what you filled. Where it leaves a genuine fork — two plausible readings, materially different models — name the fork, implement the reading you recommend, and say what changes if they meant the other. What this step must never do is stop the work. A question raised instead of a file leaves the user with nothing to correct; a question raised beside a file they can already run costs them one sentence to answer. Deliver the file either way. ### 1.4 Apply the enhancement — in place, and additively Now edit the document. Work from the original bytes, in the original order, and keep everything you were not asked to change. **The preservation rules.** These are not style preferences — each one names a way an enhancement has been observed to destroy something, and none of them is reported by the checker: | Rule | What breaks when it is ignored | |---|---| | **Keep every line you were not asked to change** | The rewrite-from-memory failure. Anything the original had and your version does not is a silent loss | | **Keep the document's order** | Sections are read top to bottom and a reader knows where things are. Reordering turns a two-line change into an unreviewable diff | | **Never rename anything silently** | An entity name is a foreign-key prefix, an `%%rbac` target, a rule binding and a table name in every `%%report`. Rename it and all four must move together — and the generated application's existing data is in a table that no longer exists | | **Never drop help text** | `%%entity help:` and `%%field help:` are `sys_table.description`, `sys_column.description` and the whole of the generated manual's prose. Nothing complains when they go; the application just stops explaining itself | | **Never narrow an enum a state machine uses** | Remove a value and every state bound to it is a state the machine can no longer reach. The diagram still draws | | **Never drop a `%%report`** | It is a question somebody's users actually ask, written as the SQL that answers it. It is also the easiest thing in the document to lose, because nothing else refers to it | | **Keep the existing style** | Naming, casing, help-text voice, directive spelling. Your lines should be indistinguishable from the ones already there | | **Add, do not replace, unless asked** | "Also track X" means the model gains X. It does not mean the model becomes X | **Follow the blast radius of every change.** A change is rarely one line, and the lines it drags with it are spread through the document: | When you… | Also update | |---|---| | Add an entity | Its relationships at both ends; a `%%rbac … .read` for every role that works with it; help on it and every column; `%%enum` and `%%field … enum:` for any closed vocabulary; a `%%category` if the document groups entities; `%%entity … parent:` if it is a line item | | Add a column | Its type and modifiers; its help text; its enum binding if it is a vocabulary; the `FK` modifier **and** a relationship line if it points at another entity; any `%%report` that should now select it | | Add a state | The `%%enum` behind the status column must gain the value; the entries and exits must still reach `[*]`; any `%%rbac` on the new transition | | Add a rule | Its `on event: ` binding; whether it merely decides or must also act — a rule that must act needs `%%action` | | Add a role | One `%%rbac … .read` per entity that role works with. A role that reads nothing is an account that signs in to an empty application | | Rename anything | Every reference to the old name, everywhere: foreign-key prefixes, relationship lines, `%%rbac`, `%%rule`, `%%workflow`, `%%hook`, `%%step`, `%%field`, `%%index`, and the SQL inside every `%%report` | | Remove anything | Everything that names it, by the same list | **The file contract is unchanged, and it still applies to every byte you hand back**: one file, UTF-8, first non-blank line a `%%` line, `%%meta name:` before the first Mermaid keyword, every other line a Mermaid statement or an EML `%%` directive or blank, and no Markdown anywhere in it. An enhanced model that has picked up a heading or a fence on its way through your hands is `EML004` like any other prose. Keep the `%%meta name:` the model already had unless the user asked for the business to be renamed. And if the document carries `%%meta version:`, raise it — a model that changed and did not is a model two people will disagree about. ### 1.5 Validate, and prove nothing was lost The enhancement is not finished when it is written. It is finished when the checker accepts it **and** the inventory says the model still contains what it contained before. Those are two separate questions and only one of them has a tool. **First, the checker — exactly as the authoring protocol runs it.** If you have a shell, this is the whole of it: ```sh curl -sO https://www.appwithai.org/guide/check-model.mjs node check-model.mjs my-business.mmd ``` Run the three passes, apply the fixer to every diagnostic, re-run the checker, and repeat that cycle **at least 10 times or until the report is empty**, whichever comes first. Validate the file, not your message: `check()` takes the contents of the enhanced `.mmd` and nothing else. This document's validation section has the modules, the API and the offline routes; §1.2's baseline is what tells you whether a diagnostic is yours or was already there. Do not deliver a document that still has diagnostics after 10 correction attempts. Deliver the best version you have, say plainly which diagnostics remain, which of them were in the original, and what you tried. **Perform the validation; do not offer it.** "I can run the checker if you would like me to" is not a step, it is a question — and the user asked for an enhanced model, not for a decision about whether to check one. Run it. **If you cannot reach the checker, exhaust the ladder before you believe it.** Every observed case of "the checker could not be run" has been one route failing while the others went untried: a `curl` that is unavailable, a URL import Node declines, a host that will not resolve, a proxy answering 403 or 502. Each of those is one rung, not a verdict on the environment. The engine is two dependency-free ES modules — get `checker.js` and `fixer.js` into the working directory by any means available, including a copy the user pastes in, and `check-model.mjs --base ./` runs against them with no network at all. **Running the checker needs no specification document at all.** This file being unreachable, or any other document, has no bearing on whether the engine runs — they are different fetches of different things, and conflating them is how a blocked documentation URL turns into a model delivered unvalidated. By the same token, failing to reach GitHub says nothing about whether the checker can run: no route to the checker passes through a GitHub host. Only when every rung has genuinely failed: walk the delivery checklist by hand, then hand over the file with one plain sentence — the checker could not be run here, this is what was verified by hand, and the user can validate it in their own browser in a few seconds at `https://www.appwithai.org/guide/11-check-a-model.html`, which runs these same two modules with nothing to install. Never report the output of a check of your own devising in the vocabulary of the real one: a substitute described as "passes" or "0 warnings" is worse than no check at all, because the user cannot tell the two apart. **Second, the regression comparison, which no tool will do for you.** Put the §1.2 inventory beside the same counts taken from the enhanced file. Every number must have gone **up, or stayed the same** — unless the user asked for it to go down: | If this fell and nobody asked | You have | |---|---| | Entity count, or an entity name vanished | Dropped an entity | | Columns on an entity you did not touch | Rewritten it from memory | | Enum values | Broken every state and rule bound to the missing one | | `%%entity help:` / `%%field help:` lines | Emptied the generated manual | | `%%rbac` lines | Made an entity invisible to a role that could see it yesterday | | `%%report` directives | Deleted a question somebody's users ask | | Hooks, rules, actions, steps, workflows | Removed behaviour the application was running | A fall in any of these that the user did not request is a defect, not a simplification. Find what you dropped and put it back, then run the checker again — the fix is a new edit and the file must be re-validated after it. **Read the enhanced file once more, end to end**, looking for the two things counts cannot see: a reference to something you renamed that you did not follow, and a new entity or column with no help text. Both check clean. ### 1.6 Deliver **Run the loop one last time, over the exact bytes you are about to hand over.** Editing stops; validation starts again from zero — check, fix, check, until the report is empty. The file that leaves your hands must be the one that came out of a clean run, not an earlier draft and not "clean three edits ago". Hand back the **whole enhanced model** as a file the user can download, under the name it arrived with unless they asked for a different one. - If you can write to a filesystem, write it there and attach it. - If your surface has a file, download or artifact mechanism, use it, with the `.mmd` extension intact — not `.txt`, not `.md`, not `.diff`. - **Only if the surface truly cannot carry a file**: print the complete document inside a single triple-backtick `mermaid` fence, the file name on the line above it, the file's bytes and nothing else between the fences, and nothing left out. No elisions, no "unchanged from here down", no splitting one model across two blocks. The user saves that block verbatim. - **Never abbreviate the unchanged parts.** "…the rest of your model as before…" inside the fence turns the deliverable into a merge the user has to perform, and hands them a file that is `EML004` if they save it as it stands. - **Do not offer the by-products.** The original, a diff, a change log as a file: leave them behind. One attachment. Alongside the file — in the reply, never inside it — give: - **what changed**, as a short list: what was added, what was changed, what was removed, and anything you inferred rather than were told; - **what was preserved**, as the regression table from §1.5 — the before and after counts, side by side. This is the evidence that the enhancement did not cost anything, and it is the part a user cannot check for themselves without reading two hundred lines of Mermaid; - **the checker result** — errors, warnings and infos, with counts — for the original as received *and* for the file you are delivering, or the plain statement that the checker could not be run here and what you verified by hand; - **anything still unresolved**, and any pre-existing diagnostic you deliberately left alone; - **what to do next**: upload the file at `https://www.appwithai.org/guide/run-in-browser.html#upload`, where it becomes a running application in the browser tab, with nothing installed and nothing uploaded to a server. One last check before you send. Open the file you are about to attach, read its first non-blank line, and count its entities. If the line does not begin with `%%`, you are attaching the wrong artifact. If the count is lower than the one you wrote down in §1.2, you are attaching a regression. ## 2. Document structure A document is UTF-8 text holding one or more **sections**. A section opens at a Mermaid keyword and runs to the next one. | Opening keyword | Section | |---|---| | `erDiagram` | ERD | | `flowchart` / `graph` | business rules **or** workflow | | `stateDiagram-v2` | workflow (state-machine form) | A `flowchart` is read as **rules** when preceded by `%%meta kind: rules`, or when it contains only decision/expression/function/io shapes and no `%%hook` directives. Otherwise it is a **workflow**. Say which you mean — mark every rules section `%%meta kind: rules` rather than relying on inference. Directives are Mermaid comments and may sit inside or beside their section: ``` %%meta name: Acme Dance Studio %%meta kind: erd %%meta version: 1.0.0 ``` `%%meta name:` is required — its absence is `EML001`, one of the five codes the fixer repairs. Give the document a real business name rather than letting the fixer guess one from the first entity. A minimal, complete, checker-clean document: ```mermaid %%meta name: Minimal Example %%meta kind: erd erDiagram Customer { string id PK string email UK string name } %%entity Customer help: A person or organisation that buys from us. Created when a lead converts, and never deleted while an unpaid invoice stands against it. %%field Customer.email help: The address invoices and order confirmations are sent to, and the customer's identity here. Unique, because two records on one address split a customer's history in two. %%field Customer.name help: The trading name to address on correspondence and to print on an invoice. Not necessarily the registered legal entity. ``` --- ## 3. The ERD section ### 3.1 Entities and attributes ```mermaid %%meta name: Studio Booking %%meta kind: erd %%enum ClassStatus: scheduled, running, completed, cancelled erDiagram Instructor { string id PK string full_name string email UK boolean is_active } Room { string id PK string name integer capacity } DanceClass { string id PK string instructor_id FK string room_id FK string title string status datetime starts_at integer capacity decimal price OPTIONAL } Instructor ||--o{ DanceClass : "teaches" Room ||--o{ DanceClass : "hosts" %%field DanceClass.status enum: ClassStatus %%index DanceClass(instructor_id, starts_at) %%entity Instructor help: Somebody who teaches at the studio. Instructors are booked against classes, so deactivating one has to leave their past classes attributed rather than delete them. %%field Instructor.full_name help: The name that appears on the timetable, which is how members choose a class. Use the name the studio advertises, not the one on the contract. %%field Instructor.email help: Where the schedule and cancellation notices are sent, and how the instructor signs in. Unique, because it identifies the person. %%field Instructor.is_active help: Whether the instructor is currently teaching. Cleared instead of deleting, so the timetable history and the members' attendance records stay intact. %%entity Room help: A physical studio a class can be held in. Rooms are the constraint the timetable is built against: two classes cannot share one, and capacity is what caps a class's bookings. %%field Room.name help: What the room is called on the timetable and on the door. Members navigate by it, so keep it the name actually painted on the wall. %%field Room.capacity help: The most people the room legally holds, including the instructor. It is the ceiling on a class's own capacity, and it is a fire regulation rather than a preference. %%entity DanceClass help: One session at one time in one room with one instructor. The class is what a member books onto and what the studio is paid for, and its status is the single answer to whether it is going ahead. %%field DanceClass.instructor_id help: Who is teaching this session. Changing it is a substitution, and members who booked for a named instructor generally want to be told. %%field DanceClass.room_id help: Where the session is held. It caps the class's capacity and is what a member is looking for when they arrive. %%field DanceClass.title help: What the class is called on the timetable — the style and level a member is choosing between. "Beginners' salsa", not "Class 14". %%field DanceClass.status help: scheduled — open for bookings; running — under way; completed — taught, and countable in attendance; cancelled — called off, and every booking on it has to be refunded or moved. %%field DanceClass.starts_at help: When the session begins, in the studio's own time zone. It is what the timetable is ordered by and what a reminder is sent against. %%field DanceClass.capacity help: The most bookings this session will take, at or below the room's own limit. Set lower than the room where the style needs floor space. %%field DanceClass.price help: What a member pays for this session if they are not using a class pack. Empty where the class is included in a membership. ``` One entity block per entity. One attribute per line: ` [MODIFIERS]`. Entity names are `PascalCase` or `SNAKE_CASE`; attribute names are `snake_case`. `id PK` is added automatically when an entity declares no primary key, but declaring one yourself is better — `EML117` warns when you do not, and the repair prepends `string id PK`, which may not be the column you wanted. **Seven column names are the generator's — do not declare them.** Every generated table carries them already, in both stacks: ``` id version created_at updated_at created_by updated_by deleted_at deleted_by ``` Writing `datetime created_at` in an entity block says something the generator already guarantees. `EML103` reports the line, `checkAndFix` deletes it (§8.3), and the generator keeps its own definition regardless. Until it learned to, the duplicate reached PostgreSQL as a `CREATE TABLE` naming one column twice — which is rejected outright, so the *application failed to open*, with a message (`column "created_at" specified more than once`) that named no model line at all. Model what your business knows; the record-keeping columns are already there. The `_id` spelling is a different column and a legitimate one: `created_by` is the generator's audit stamp — a bare UUID with no lookup — while `created_by_id FK` is a reference you modelled, and it resolves to your model's person entity with a lookup on it (§3.5). If you want the author of a record to be selectable and shown by name, declare `created_by_id FK`, not `created_by`. ### 3.2 Types Eight canonical types. Everything else normalises into one of them; an unknown type falls back to `string` and raises `EML115`. | Canonical | Written as any of | |---|---| | `string` | `string` `varchar` `char` `uuid` `guid` `id` `email` `url` `phone` `password` `color` | | `text` | `text` `longtext` | | `integer` | `integer` `int` `bigint` `smallint` | | `decimal` | `decimal` `float` `double` `number` `money` `amount` | | `boolean` | `boolean` `bool` | | `date` | `date` | | `datetime` | `datetime` `timestamp` `time` | | `json` | `json` `jsonb` `object` `array` | The alias you choose is not cosmetic even though it normalises: `email`, `phone`, `url`, `password` and `color` all become `string` in SQL, and the word you wrote is kept — it is what the Application Dictionary records as the column's reference type, and therefore what decides the control the user gets (§3.7). Write `email email`, `phone mobile`, `url website`, `password secret` and `color brand_colour`; a column declared `string` gets a plain text box even when its name says otherwise. Prefer the specific alias over the canonical name wherever one fits. ### 3.3 Modifiers | Modifier | Meaning | |---|---| | `PK` | Primary key. Implies unique. | | `FK` | Foreign key. Marks the column as a reference. | | `UK` / `UNIQUE` | Unique. | | `OPTIONAL` / `NULL` | Nullable. | **Defaults**: every column is *required* unless marked `OPTIONAL` or `NULL` (or it is the `PK`), and *not unique* unless marked `UK`, `UNIQUE` or `PK`. Anything that is not one of these six words is **dropped silently** by the parser — `string email UNQIUE` yields a column that is simply not unique and renders identically in the diagram. `EML118` warns about it. Read that warning. ### 3.4 Relationships and cardinality Mermaid's ER grammar lets you pair any left marker with any right marker. **EML reads these eight spellings, and only these eight:** | Operator | Kind | Example | |---|---|---| | `\|\|--\|\|` | one to one | `User \|\|--\|\| Profile : has` | | `\|o--o\|` | one to one | `Employee \|o--o\| ParkingSpot : assigned` | | `\|\|--o{` | one to many | `Company \|\|--o{ Contact : employs` | | `\|\|--\|{` | one to many | `Order \|\|--\|{ OrderItem : contains` | | `}o--\|\|` | many to one | `Deal }o--\|\| DealStage : in_stage` | | `}\|--\|\|` | many to one | `OrderItem }\|--\|\| Order : belongs_to` | | `}o--o{` | many to many | `Student }o--o{ Course : enrolls` | | `}\|--\|{` | many to many | `Author }\|--\|{ Book : writes` | **A relationship spelled any other way is dropped in silence.** `Appointment ||--o| Consultation : "becomes"` is valid Mermaid and renders exactly as drawn, but no relationship reaches the model — and nothing points at the line. What you get instead is `EML502` on a foreign key whose parent "has no relationship", or `EML503` on an entity that now looks isolated. When a diagnostic denies a relationship you know you drew, check its operator against the eight above before anything else. Optional-on-the-many-side is `||--o{`; optional one-to-one is `|o--o|`; there is no eight-operator spelling for "exactly zero or one child", so model it as one-to-many and let the rule enforce the rest. The label after the colon is the verb of the relationship and appears in the generated interface. Write a real one — `"places"`, `"employs"`, `"hosts"` — not `"has"` repeated. ### 3.5 Foreign keys A foreign key resolves **by name**: a column ending `_id` points at the entity whose snake_case name it carries. `customer_id` finds `Customer`; `sla_policy_id` finds `SlaPolicy`. - A foreign key that does not end `_id` cannot be resolved: `EML114`, and the fixer appends the suffix. - **The prefix has to match the entity's name as the parser derives it.** `purchase_order_line_id` finds `PurchaseOrderLine`. It does not find `POLine`, because `po_line` reads back as `PoLine` — an entity whose name runs two capitals together cannot be reached by a foreign key. Name such an entity `PurchaseOrderLine` rather than trying to spell the column around it. - **Columns naming a person resolve to the model's person entity, but only by an exact list.** Anything ending `_by` or `_by_id` — `created_by_id`, `approved_by_id`, `received_by_id` — plus these names: `assigned_to`, `author_id`, `lab_manager_id`, `manager_id`, `owner_id`, `pi_id`, `remediation_owner`, `remediation_owner_id`, `user_id`. Nothing else. `approver_id` derives `Approver` and `assigned_to_id` derives `AssignedTo` — entities nobody declared. Name person columns `_by_id` (`approved_by_id`) or use `owner_id`. - **A name that resolves to nothing falls back to a relationship you drew.** If the entity declares a parent that no other foreign key on it accounts for, the column is bound to that parent — so `ImagingReport.radiologist_id` finds `Doctor` when the model says `Doctor ||--o{ ImagingReport`, rather than hunting for a `Radiologist` nobody wrote. The generator resolves it this way, and the checker agrees rather than reporting a column that in fact works. `EML502` remains for the case with nothing to fall back on: a foreign key whose name matches no entity *and* whose entity declares no spare parent. Relying on the fallback is still worse than naming the column well — it is unambiguous only while the entity has exactly one unclaimed parent. - **Which entity that is, is read off your model.** `User` if you declared one, otherwise `Staff`, otherwise `Employee` — the first of the three that exists. So `pi_id` in a research model with a `User` entity resolves to `User`, and `reported_by_id` in a hospital model that has `Staff` and no `User` resolves to `Staff` rather than raising `EML502` for a `User` entity you never wrote. A model with none of the three still resolves to `User`, which is the diagnostic you want: declare a person entity, or the reference has nothing to point at. - **Two names on that list are traps**: `assigned_to` and `remediation_owner` are recognised, but they do not end `_id`, so they raise `EML114` — and the repair renames them to `assigned_to_id` and `remediation_owner_id`, which are *not* on the list, so `EML502` appears where a warning was cleared. It is the one repair that can leave a new diagnostic behind. Do not use those two names; write `owner_id`. - Declare the matching relationship line as well as the column. A `FK` with no relationship raises `EML502`, and the generated interface loses the navigation the relationship would have given it. ### 3.5.1 Parent and child — line items Some entities have no life away from their owner: an invoice line, an order line, a prescription item. **The ERD cannot express this**, which is the whole reason the directive exists — `InvoiceLine.invoice_id` and `Invoice.patient_id` are both a foreign key with a relationship behind it, and nothing in Mermaid says that a line means nothing without its invoice while a patient means a great deal without one. ``` %%entity InvoiceLine parent: Invoice ``` **Decide it with these questions, in this order:** 1. Would a list of these records, *away from their owner*, be useful to anyone? A screen of every invoice line ever written is not a screen anyone opens. If no — it is a child. 2. Does the row's identity depend on the owner? "Line 1 of invoice 7", not "line 1". If yes — it is a child. 3. Would deleting the owner make the row meaningless? If yes — it is a child. A **reference** is the opposite on all three: `Invoice.patient_id` points at a Patient who exists, and matters, on their own. Most foreign keys are references. Line items are the minority, and they are usually obvious once the questions are asked — the noun is a *line*, an *item*, a *detail*, an *entry*. **What it does to the application:** | | Parent | Child | |---|---|---| | `sys_window` | its own | **none** | | Dashboard | a card | **no card — not navigable** | | `sys_tab` | `tab_level: 0` | `tab_level: 1`, in the *parent's* window | | Reached by | opening the window | opening a parent record | The tab links on the child's own foreign key back to the parent — the one already in the ERD, marked `sys_column.is_parent` and stored as `sys_tab.link_column_id`. **Do not declare a second column for it**, and do not drop the relationship line: the directive names the parent, the ERD still draws the edge. Four codes police it: | Code | Severity | Fires when | |---|---|---| | `EML147` | error | the parent is not declared, or an entity names itself | | `EML148` | error | the child has no foreign key back — nothing for the tab to link on | | `EML149` | info | an entity is *shaped* like a line item and declares no `parent:` | | `EML150` | warning | a declared child is still named in a `%%category` | `EML147` and `EML148` are errors because a line item with nothing to link on loses its own window and gains no tab, which is an entity that has quietly left the application. `EML149` is an **info and never an error**, because the three questions above are about the business and not about the document: only you know whether a list of these records away from their owner is a screen anyone opens. The checker can only say that the shape is there, and it names both the candidate parent and the column a tab would link on. It fires on two shapes, each of which also requires that foreign key: an entity whose name **begins** with a declared entity's name (`InvoiceLine`/`Invoice`, `TeamMember`/`Team`, `FinancialPlanAssumption`/`FinancialPlan`), and an entity whose name **ends** in a line-item noun — `Line`, `LineItem`, `Item`, `Detail`, `Entry`, `Row` — whose foreign key resolves to a declared entity (`RecommendationItem` under `InvestmentRecommendation`). **Answer every `EML149` before you deliver.** Either declare the parent, or satisfy yourself that the entity is a thing in its own right and leave it — an `EML149` you have walked and rejected is not a fault, and the run still reports `OK`. What is not acceptable is not having looked: if nobody decides, the default is a dashboard card listing every line ever written and a parent record that does not show its own lines. `EML150` is a warning rather than an error because nothing breaks — but the two directives contradict each other. `parent:` takes the child's dashboard card away and the category asks for one, so the group counts an entity the reader can never open from it. Take the child out of the `entities:` list. **What the running application does with it.** Both stacks read the same three rows and nothing else. Opening a parent record shows one tab per line item below the form, each listing only the rows whose `is_parent` column holds that record's key, with a live count on the tab and a row click that opens the child. The dashboard and the navigation are built from the entities that have a window, so the child appears in neither. None of that is decided by a screen — it is `sys_tab.tab_level`, `sys_tab.link_column_id` and `sys_column.is_parent`, which is why the model's answer and the application's arrangement cannot drift apart. A child is still a real table with real rules, access control and a form. Only its *placement* changes. Leave it out of `%%category`: a category lists what appears on the dashboard, and a child does not. ### 3.6 Enums, field metadata, indexes and grouping ``` %%enum LeadStatus: new, working, qualified, converted, disqualified %%field Lead.status enum: LeadStatus %%field Lead.score help: 0-100, set by the scoring rule. Anything over 70 routes to a salesperson. %%entity Lead help: Someone who has shown interest but is not yet an account. %%index Lead(campaign_id, status) %%index Contact(email) unique %%category name: Sales; description: Leads and the deals they become; icon: TrendingUp; color: #0ea5e9; entities: Lead, Opportunity ``` - **`%%enum`** declares a closed vocabulary. Reference it from a column with `%%field`, and from a state machine's states (§5.2). - **`%%field . enum: `** binds a column to an enum. The enum must exist — `EML501` otherwise. - **`%%field . help: `** is the help text for the column. It becomes `sys_column.description`, which the generated form prints under the control and the Application Dictionary shows beside the column. `enum:` and `help:` are the two `%%field` keys that compile; the others (`ui`, `default`, `min`, `max`, `format`) are accepted and validated only. - **`%%entity help: `** — or `description:`, the same key — is the sentence that explains the entity. It becomes `sys_table.description`. **Help is not optional. Write `%%entity help:` for every entity and `%%field . help:` for every column, without exception.** This is the most-skipped part of a model and the most expensive to skip. The generated application has no other source of explanation: no tooltips written by hand, no README beside the form, no designer to ask. What you write here is what a user reads under the control, what the Application Dictionary shows beside the column, and the whole of the manual the generator ships. Leave it out and the application still runs — which is exactly why it gets left out — but every field on every form is a label with nothing behind it. - **Every entity**, including the self-evident ones. "Invoice" is not a description of an invoice. - **Every column**, including `status`, the foreign keys and the enums. A reference column should say what the reference is *for* — "the ward this bed stands in" — not restate its type. - **Say what it is for, not what it is.** `%%field Lead.score help: 0-100, set by the scoring rule. Anything over 70 routes to a salesperson.` tells a reader something. `The score of the lead.` does not. - **Nobody adds it later.** The model is the only place it can be written, and the moment it is being written is the only moment anyone knows the answer. **And it has to be domain knowledge, not the name in a sentence.** This is where the *business* lands in the model, and nothing else in the document carries it. The ERD says an entity has a `household_id`; only help says that a membership belongs to a family and that the family's screen is where it is read. Everything else — types, keys, cardinalities, states, roles — is structure, and structure is the half a reader can already see. Help is the half that has to be *known*: what the business calls this, why it keeps it, what depends on it, what goes wrong when it is wrong. Write it from the research and the brief, in the business's own vocabulary. **One test, applied to every line before you deliver: could this sentence have been written without knowing anything about this business?** If yes, it is not help. ``` Bad: %%field HouseholdMember.household_id help: Household id for HouseholdMember. Good: %%field HouseholdMember.household_id help: The family this membership is in. Listed inside the household's own screen — a membership away from its household is not something anybody looks up. Bad: %%entity Address help: Address is a business record in the platform. Good: %%entity Address help: A postal address belonging to a party, kept as its own record because a party has several — registered, correspondence, often an overseas one — and because a change of address is a KYC event that has to be evidenced rather than typed over the old one. ``` The first of each pair would pass any coverage check and leaves the reader exactly where they started. **Three codes report the three ways a model fails here, all warnings:** | Code | Fires when | |---|---| | `EML152` | an entity carries no `%%entity … help:` at all | | `EML153` | an entity has columns with no `%%field … help:` — reported once per entity, naming them | | `EML151` | help that restates its own subject instead of describing it | `EML151` catches three shapes: `Unique identifier for X`, the column name in prose (`Status for Client`), and a template sentence (`X is a business record in …`). It is deliberately narrow, so real help that happens to be short — *The day this offer expires.* — does not fire. It exists because coverage can be complete and the help still worthless: a published model once carried help on all 91 of its entities and all 551 of its columns, and 699 of those lines were of exactly these shapes. **The primary key needs no help, and `EML153` does not ask for any.** It is a generated uuid, read-only on every form, and the only sentence anybody could write about it restates its name — which is what `EML151` then reports. Leave it out. **What to say, per kind of column:** | Column | Say | |---|---| | A reference (`_id`) | what the reference is *for* — "the ward this bed stands in", never "the ward id" | | An enum-bound column | what each value means to the business, because the dictionary lists the values and nothing says what choosing one *does* | | A lifecycle column | which moves are possible from which state — the state machine enforces a topology no form can show | | A money column | gross or net, which currency, and what it is compared against | | A date | which business event it records, and what is true before it is set | | A flag | what turning it on permits or prevents, and who may do so | A delivery missing help is incomplete even though the checker returns zero errors — §10's checklist asks for all three codes. - **`%%index ([, ]) [unique]`** becomes a real index. Add one for every column combination the business searches or filters on, and no more — every index is paid for on every write. - **`%%category`** groups entities on the generated dashboard. `name` is the only required key; `entities` is a comma-separated list. Only `;` separates keys. **There is no shorthand**: `%%category Sources: DataSource, SchemaEntity` reads perfectly well and declares nothing, because the parser requires `name:` and skips the line without it — the grouping is lost and every entity in it falls into the default `General` category. Reported as `EML154`. ### 3.7 What the Application Dictionary makes of your ERD The generated application does not hard-code its forms. Every table, column, tab, field and lookup is a row in an **Application Dictionary** — `sys_table`, `sys_column`, `sys_field`, `sys_tab`, `sys_window`, `sys_category`, `sys_reference`, `sys_ref_list` — and the running interface reads those rows at runtime, which is why a field can be added to a live application without a deployment. **You never write dictionary rows, and there is no `%%dictionary` directive.** The dictionary is derived, one way, from the ERD you declare — so "populating the dictionary" means declaring the ERD precisely enough for the derivation to be right. The property that decides what a user actually sees is the column's **reference type**: | What you write | Reference recorded | What the user gets | |---|---|---| | the entity's `PK` | `ID` | the key, hidden in forms | | `string vendor_id FK` — the `FK` modifier **and** a name ending `_id` or `_by` | **`Table Direct`** | a lookup on the parent table | | a column bound by `%%field . enum: ` | `List`, plus one `sys_ref_list` row per value | a dropdown of exactly those values | | a column declared with the `email` `phone` `url` `password` `color` alias (§3.2) | `Email` `Phone` `URL` `Password` `Color` | the matching input control — an e-mail keyboard, a masked box, a link | | `text` | `Text` | a memo box | | `boolean` | `Yes-No` | a switch | | `decimal` / `money` | `Amount` | a right-aligned amount | | `date` / `datetime` | `Date` / `DateTime` | the matching picker | | `json` | `JSON` | a JSON editor | | anything else | `String` | a plain text box | **Two mistakes downgrade a column to `String`**, and they are the reason a generated application comes back with raw ids in text boxes. Both were silent until the checker learned to report them — `EML119` for the first, `EML146` for the second — and both still render exactly as drawn in a Mermaid viewer, so the report is the only place they show: 1. **A reference column without the `FK` modifier.** `string vendor_id` and `string vendor_id FK` parse into the same column, and only the second becomes `Table Direct`. Without the modifier the dictionary records `String`, and the form shows a bare uuid where the vendor lookup should be. The name must end `_id` or `_by` as well (§3.5) — both conditions, every time. 2. **An enumerated column with no `%%field` binding.** Declaring `%%enum` does nothing to a column on its own. Without `%%field Order.status enum: OrderStatus` the dictionary records `String`, and a user gets a free-text box where the state machine expects one of five values. **Give every referenced entity a name column.** A reference stores a uuid, and every screen that shows one has to turn it back into something a person recognises — the lookup dropdown, and the grid cell. The dictionary decides what a record is *called* from its **identifier columns** (`sys_column.is_identifier`), concatenated in declared order: | What the entity declares | What a reference to it shows | |---|---| | `name` — or `full_name`, `display_name`, `title`, `label`, `subject` | that column | | `first_name` **and** `last_name` | both, joined — `Omar Kowalski` | | `code`, `reference` or `number` | that column — `CON2026-0001` | | **two or more `FK` columns and no name of its own** | its first two parents, resolved and joined — `Spring Promo — Omar Kowalski` | | none of those | the first plain `string`/`text` column you declared | | no text column at all | the uuid, which is the outcome to avoid | So an entity that will be pointed at wants a name. `Account` with a `name` renders as *Northwind Systems* everywhere it is referenced; the same entity without one renders as whatever text column happens to come first — a billing city, a status — and a reader has no way to tell one record from another. The primary key is never an identifier: a display value beginning with a uuid is the thing this mechanism exists to prevent. **A join entity is the exception, and it needs nothing from you.** An entity whose identity *is* the pair of records it joins — `CampaignMember`, `OrderLine`, `QuoteLineItem` — has no name to give it, and the row above it would have picked whatever text column came first: `member_status`, so every campaign member read *invited, invited, responded*. Two or more `FK` columns and no name of its own is the shape the dictionary recognises, and it labels the record by resolving its first two parents through *their* labels: ``` CampaignMember { string id PK string campaign_id FK string contact_id FK string member_status } ``` renders as `Spring Promo — Omar Kowalski`. Only the first two, and only one level deep: a label assembled from four grandparents is not a name anybody reads. **Declare the two parents first** if the entity has more than two references — `CampaignMember` pointing at a campaign, a lead *and* a contact labels itself from the first two in declared order, which is the only say you have in it. **The dictionary also decides what the sample rows look like.** A generated application is seeded with rows so it can be opened and used — ten per entity by default — and the values come from faker.js, but *which* faker generator a column gets is read off the dictionary rather than guessed from the column's type. A column whose reference type is `EMAIL` holds an address, one bound to a `%%enum` holds one of that enum's declared values, and a `Table Direct` column holds the id of a row that exists, because entities are seeded parents-first. This is the second reason the two mistakes above matter: a reference column downgraded to `String` is seeded with words rather than with a record, so the first thing you see on opening the application is a lookup that resolves to nothing. **The help text is part of the dictionary, and only you can write it.** A column's `sys_column.description` is what the form prints under the control, and a table's `sys_table.description` is what the dictionary and the entity's screen show beside it. Both come from directives, and from nowhere else: ``` %%entity Contact help: A person at an account. Deals and cases hang off the contact you actually deal with. %%field Contact.account_id help: The company this person works for. Leave empty for a personal contact. %%field Contact.owner_id help: The salesperson responsible. Reassign when the territory changes. ``` Write the sentence a new joiner needs, not a restatement of the column name: "The company this person works for" earns its place; "The account id" does not. Cover every entity and every column: the bar is *say something the name does not*, and a column you cannot say anything about is usually a column that should not be there. **The generated manual is the second reader of that text, and it is why "every column" is not too many.** The last thing generation does is write `manual.html` beside the application — one self-contained HTML page with a contents menu, a section per entity, and inside each a table of every field with its control type, its constraints, its enumerated values and its help text, followed by the entity's relationships, its state machine, the rules that fire on it and the roles that may see it. The application's dashboard carries a **Manual** button pointing at it. Both generators write it, so it also travels in the downloadable `.zip`. The manual is derived from the model, so it cannot describe an entity that does not exist or miss one that does — but it can only report the help you wrote. Where a column has none it prints a dash, and where an entity has none at all it says so in place of the description. That is what an unhelped model looks like to whoever is handed the application: a complete, accurate list of fields that explains none of them. The rest of the dictionary follows the same way: each entity becomes a `sys_table` with a window and a tab, its attributes become fields **in the order you declared them**, `%%index` becomes real indexes, and `%%category` (§3.6) becomes the dashboard grouping — a model that declares none gets a single "General" group holding everything. The other `%%entity` keys — `label`, `icon`, `prefix`, `softDelete`, `audited` — are validated but not yet compiled (§7), so do not promise a user an icon that directive alone will not produce. So there is no second artifact to write. A dictionary that comes out right is six habits in the ERD: **mark every reference column `FK`**, **bind every enumerated column with `%%field … enum:`**, **give every referenced entity a name column**, **write `help:` on every entity and every column that needs explaining**, prefer the specific type alias over the canonical name, and group the entities with `%%category`. --- ## 4. Business rules ### 4.1 Two forms, and the difference that matters This is the single most common source of "my rule decides but nothing happens". **Node-graph form** — the shape of each node carries its role. Good for scoring, grading and pricing. It **carries no outputs, so it cannot act**: it computes a decision and nothing downstream consumes it. ```mermaid %%meta name: Lead Scoring %%meta kind: erd erDiagram Lead { string id PK string company_name email email UK integer employee_count integer score OPTIONAL string rating OPTIONAL } %%entity Lead help: A company that has shown interest but has not yet been qualified by a salesperson. A lead becomes an account only on conversion, so nothing is sold against one. %%field Lead.company_name help: The organisation as it identified itself. It is what a salesperson searches by before creating a duplicate. %%field Lead.email help: The address the enquiry arrived from and where the first outreach goes. Unique, so two enquiries from one contact do not become two leads. %%field Lead.employee_count help: How big the company is, as stated or as researched. It is the only firmographic input to the scoring rule: a thousand or more adds 35 points, anything less adds 10. %%field Lead.score help: 0-100, written by the scoring rule at creation. Empty until then, and an override by hand is a deliberate exception to the rule rather than a correction of it. %%field Lead.rating help: hot at 70 points and above, warm below it. Set by the same rule, and it is what routes the lead to a salesperson rather than to a nurture sequence. %%meta name: Lead Scoring %%meta kind: rules %%rule leadScoring on Lead event: beforeCreate priority: 10 flowchart TD A([Start: Lead captured]) --> B{employee_count >= 1000?} B -->|Yes| C[Add 35 firmographic points] B -->|No| D[Add 10 firmographic points] C --> E((Compute lead score)) D --> E E --> F{score >= 70?} F -->|Yes| G[Set rating hot] F -->|No| H[Set rating warm] G --> Z([End: Lead scored]) H --> Z ``` **Decision-table form** — a section carrying `%%action` directives. One row per directive. This is **the only form whose rules can act**. Most real rules need both: draw the decision as a graph so a person can read it, and attach `%%action` directives for the effects it must have. ### 4.2 `%%action` — how a rule acts ``` %%action when: : ... ``` `when:` is an expression over the record being written — `severity == "critical"`, `discount_percent > 15`, `status == "closed" and root_cause == null`. `true` fires on every write. It is the last key parsed before the action's own keys, so quote any value containing a `key:` sequence. | Action type | Required keys | Optional | Effect | |---|---|---|---| | `trigger-workflow` | `workflow` | `message` | Starts that workflow by name. This is what gates a `kind: saga` declared `trigger: rule`. | | `validation-error` | `message` | — | Rejects the write and returns the message to the caller. | | `transform` | `field`, `value` | `message` | Overwrites a column on the record being written. | ``` %%action escalateDiscount trigger-workflow when: discount_percent > 15 workflow: QuoteApprovalEscalation message: Discount above rep authority — holding the quote for approval %%action refuseDiscount validation-error when: discount_percent > 40 message: Discounts above 40 percent cannot be approved by anyone — reprice the quote %%action stampReviewer transform when: status == "approved" field: reviewed_by value: current_user ``` Several rows may match one write. Every output column is written in every row, blank where unused — the engine yields no result at all for a row with a missing cell, so an omitted column silently voids the rule. ### 4.3 Node shapes in a rules flowchart | Shape | Written | Role | |---|---|---| | stadium | `A([Start])` | Input context, or the decision's output when it has only incoming edges | | diamond | `B{Amount > 1000?}` | Decision. Outgoing edge labels are the branch conditions. | | rectangle | `C[Apply 15% discount]` | Expression: assign, set an output field, apply a value | | circle | `D((Calculate total))` | Function or computation step | | rounded | `E(Calculate total)` | Same as circle | ### 4.4 `%%rule` — declaring a rule's binding ``` %%rule on event: priority: ``` Binds the section to an entity and a lifecycle event (§5.1 lists the events). Lower `priority` runs first. **Status: validated, not compiled** (§7) — it is checked for correctness but does not itself change a generated application, so do not promise behaviour that depends on it alone. --- ## 5. Workflows Three kinds, declared on the section: ``` %%workflow entity: kind: ``` ### 5.1 `kind: hook` — handlers on lifecycle events Binds named handlers to the points around a write. ```mermaid %%meta name: Account Governance %%meta kind: erd erDiagram Account { string id PK string name string account_number UK OPTIONAL } %%entity Account help: A company the business actually sells to. Accounts are created on conversion from a lead and are never deleted while open business stands against them — the beforeDelete hook below is what enforces that. %%field Account.name help: The trading name to use on correspondence and invoices. Normalised on creation by the beforeCreate hook, so two spellings of one company do not become two accounts. %%field Account.account_number help: The finance system's own reference for this customer. Optional until finance issues one, unique once it exists, and checked by the customValidate hook rather than only by the database. %%meta name: Account Governance %%meta kind: workflow %%workflow AccountGovernance entity: Account kind: hook flowchart TD A[Client Request] --> B[Validate Request] B --> C[beforeCreate: normalizeAccountName] C --> D[customValidate: ensureAccountNumberUnique] D --> E[Process Account] E --> F[afterCreate: seedAccountTeam] F --> G[beforeDelete: blockDeleteWithOpenBusiness] G --> H[Response] %%hook beforeCreate normalizeAccountName on Account[field: name] %%hook customValidate ensureAccountNumberUnique on Account[field: account_number] %%hook afterCreate seedAccountTeam on Account %%hook beforeDelete blockDeleteWithOpenBusiness on Account ``` The thirteen hook types: | Type | When it runs | |---|---| | `beforeCreate` | Validate or transform before insert | | `afterCreate` | Side effects after insert | | `beforeUpdate` | Validate or transform before update | | `afterUpdate` | Side effects after update — audit, cache invalidation, notification | | `beforeDelete` | Guard before delete — block if referenced | | `afterDelete` | Cleanup after delete | | `beforeRead` | Guard or transform a single-record read | | `afterRead` | Post-process a record — redact fields | | `beforeList` | Adjust list parameters — filter, sort, paginate, scope | | `afterList` | Post-process a result set | | `beforeQuery` | Mutate a query before it runs — tenant scoping | | `afterQuery` | Post-process query results | | `customValidate` | Cross-field validation independent of a single verb | `on [field: ]` names the column the handler guards. Say it where one applies — it is how a reader knows what the handler is for. ### 5.2 `kind: state` — the lifecycle of a record ```mermaid %%meta name: Booking Lifecycle %%meta kind: erd %%enum BookingStatus: requested, confirmed, attended, cancelled, no_show erDiagram Booking { string id PK string status datetime requested_at } %%field Booking.status enum: BookingStatus %%entity Booking help: One member's place on one session, from the moment they ask for it to the moment they turn up or do not. The booking is what the studio counts capacity against, so a cancelled one frees a place and a no-show does not. %%field Booking.status help: requested — asked for, not yet accepted; confirmed — the place is held; attended — the member came; cancelled — the place is released; no_show — the place was held and wasted, which is what a repeat-offender policy is built on. Only the moves the diagram draws are allowed. %%field Booking.requested_at help: When the member asked. It orders the waiting list, so it is the time of the request rather than of the confirmation. %%meta name: Booking Lifecycle %%meta kind: workflow %%workflow BookingLifecycle entity: Booking kind: state stateDiagram-v2 [*] --> requested requested --> confirmed : confirm requested --> cancelled : reject confirmed --> attended : check_in confirmed --> cancelled : cancel confirmed --> no_show : mark_absent attended --> [*] cancelled --> [*] no_show --> [*] %%rbac role:reception|manager on Booking.confirm %%rbac role:manager on Booking.cancel ``` Rules the checker enforces: - **The entity needs a column called `status`, `state` or `stage`**, and the machine tracks that one column. No other name is recognised: an entity whose lifecycle column is `approval_status` or `fulfillment_status` gets `EML500`, and the generated application has a machine with nothing to write to. Name the column `status`; keep any second, descriptive enum column beside it. - **One state machine per entity.** Two machines bound to the same entity are two lifecycles competing for one column — the second belongs on the entity that actually lives it: approval on the approval record, receiving on the delivery, payment on the invoice. If both really describe the same row, merge them into one lifecycle with more states. - **An initial transition is required** — `[*] --> firstState`. Missing is `EML421`, auto-repaired. - **A terminal state is required** — `lastState --> [*]`. Missing is `EML422`, auto-repaired. - **Back the states with a `%%enum`** and bind it to the status column with `%%field`. The enum is matched by **value overlap**, not by name — the checker picks whichever declared enum shares the most values with the machine's states — and then three codes police the agreement: `EML428` when no enum shares a single value with it, `EML426` when a state is missing from the enum that matched, and `EML427`, an info, when the enum carries a value no state uses. A state no enum backs is a value the column can never legally hold; an enum value no state reaches is usually a transition nobody drew. Clear all three: name the enum `Status`, list exactly the machine's states, and bind it. - **Give each machine a vocabulary of its own.** Because the match is by overlap, two machines whose states are both `pending, approved, rejected` tie, and a tie goes to whichever enum was declared first — so one machine silently borrows the other's enum and reports `EML426` or `EML427` about states it never had. An order that is `pending_approval` while its approval task is `pending` costs nothing and removes the ambiguity. **A drawn edge is the only legal move.** In the NestJS application, every transition the diagram declares is seeded into a `sys_workflow_transitions` table, and the entity-access guard reads it on every write that touches the status column. A move the diagram does not draw is refused with **403 Forbidden**, and the message names the current state and the states it may legally reach — the machine constrains the data, not merely the screens. The refusal is topological rather than about roles: an administrator is refused the same move. An entity with no `kind: state` workflow seeds no rows and stays unconstrained, so a lifecycle you meant to enforce has to be drawn rather than described in prose. Draw every move you intend to allow, including the ones that go backwards: `confirmed --> requested : reopen` is one line, and without it reopening a booking is a 403. **The two questions are answered separately, and only one of them a master role bypasses.** Whether an edge *exists* is decided by the diagram and enforced from `sys_workflow_transitions` for every caller. *Who may cross* an edge that exists is `%%rbac`'s question, answered from `sys_transition_access`, and that one a master role does bypass. So an edge no `%%rbac` names is open to any authenticated caller, while an edge the diagram omits is refused to everyone. Checking topology only where a role rule happened to cover it would leave every unguarded edge open, which is why they are kept apart. `GET /api/workflows/transitions` returns the stored edges, narrowable with `?table=` and `?from=`. A screen offering a status change should ask this and offer only the moves that exist, rather than offering every state and letting the save be refused. The word after the colon is the **transition event**, and it is what `%%rbac` names to restrict who may make that move (§6). ### 5.3 `kind: saga` — a multi-step process A saga writes across entities. Each `%%step` binds a flowchart node to an executable step, and **the flowchart's edges give the running order**. ```mermaid %%meta name: Lead Conversion %%meta kind: erd %%enum LeadStatus: new, working, qualified, converted, disqualified erDiagram Lead { string id PK string company_name email email UK string status integer score OPTIONAL string owner_id FK } Account { string id PK string name string tier OPTIONAL string owner_id FK } Contact { string id PK string account_id FK email email UK } User { string id PK string full_name } Account ||--o{ Contact : "employs" User ||--o{ Lead : "owns" User ||--o{ Account : "owns" %%field Lead.status enum: LeadStatus %%entity Lead help: A company that has shown interest and is not yet a customer. Qualifying one is what starts the conversion saga below, which opens the account and its first contact. %%field Lead.company_name help: The organisation as it identified itself. It becomes the account's name on conversion, so a sloppy one propagates. %%field Lead.email help: The address the enquiry arrived from. It becomes the primary contact's address on conversion, which is why it is unique here. %%field Lead.status help: new — not yet picked up; working — being pursued; qualified — accepted by sales, and the value that fires the conversion saga; converted — an account exists; disqualified — not a fit, and kept so the same enquiry is not worked twice. %%field Lead.score help: 0-100, from the scoring rule. Above 85 the conversion saga opens the account at the strategic tier rather than as an SMB. %%field Lead.owner_id help: The salesperson pursuing it. They inherit the account on conversion, which is why it is set before qualification rather than after. %%entity Account help: A company the business sells to, opened by the conversion saga when a lead is qualified. It is the record everything commercial hangs off, and it outlives the lead that produced it. %%field Account.name help: The trading name on correspondence and invoices, copied from the lead's company name at conversion and correctable afterwards. %%field Account.tier help: strategic or smb, decided by the conversion saga from the lead's score. It sets the service level the account is entitled to, so changing it by hand is a commercial decision. %%field Account.owner_id help: The salesperson responsible. Inherited from the lead, and reassigned when the territory changes. %%entity Contact help: A person at an account. The conversion saga creates the first one from the lead's own email address, and the rest are added as the relationship widens. %%field Contact.account_id help: The company this person works for. It is what a deal or a case is filed under, so a contact on the wrong account misfiles everything about them. %%field Contact.email help: How this person is reached, and their identity here. Unique across contacts, because the same address at two accounts is one person who has moved. %%entity User help: Somebody at this business who owns leads and accounts. Users are the assignable side of the pipeline: an unowned lead is one nobody is working. %%field User.full_name help: The salesperson's name as it appears on a lead's owner field and in an account's history. %%meta name: Lead Qualification %%meta kind: rules %%rule leadQualification on Lead event: beforeUpdate priority: 15 flowchart TD Q([Start: Lead updated]) --> R{status == qualified?} R -->|Yes| S[Convert the lead] R -->|No| T[Leave it open] S --> QZ([End: Qualification decided]) T --> QZ %%action convertQualifiedLead trigger-workflow when: status == "qualified" workflow: LeadConversion message: Lead qualified — opening the account %%meta name: Lead Conversion %%meta kind: workflow %%workflow LeadConversion entity: Lead kind: saga trigger: rule flowchart TD A([Lead qualified]) --> B{Size the account} B --> C[Open the account] C --> D[Create the primary contact] D --> E[Mark the lead converted] E --> Z([Converted]) %%step B Decision decisionTable: {"hitPolicy":"first","inputs":[{"id":"i1","name":"Score","field":"score"}],"outputs":[{"id":"o1","name":"Tier","field":"accountTier"}],"rules":[{"_id":"strategic","i1":">= 85","o1":"'strategic'"},{"_id":"rest","i1":"","o1":"'smb'"}]} %%step C CreateEntity entity: Account as: newAccountId fields: {"name":"company_name","tier":"accountTier","owner_id":"owner_id"} %%step D CreateEntity entity: Contact as: newContactId fields: {"account_id":"newAccountId","email":"email"} %%step E UpdateEntity field: status value: converted ``` `trigger: rule` means the saga starts only when a rule's `trigger-workflow` action names it (§4.2). Without that, declare no trigger and it runs on the operation the workflow is bound to. **The seven step types:** | Step | Required | Optional | Purpose | |---|---|---|---| | `CreateEntity` | `entity`, `fields` | `as` | Insert a row; `as` publishes its id for later steps | | `UpdateEntity` | `field`, and one of `source` / `value`; **plus `targetSource` or `targetField` whenever `entity` is set** | `entity` | Write one column, on this record or a related one | | `DeleteEntity` | **`targetSource` or `targetField` whenever `entity` is set** | `entity`, `hard` | Remove the triggering record, or related rows. Soft by default — `hard: true` really deletes | | `Formula` | `target`, `operation`, and what that operation needs | — | Compute a value and publish it under `target` | | `Decision` | `decisionTable` **or** `rule` | `publish` | Evaluate a decision table inline, or a named rule | | `REST` | `url` | `method`, `bodyTemplate` | Call an external endpoint | | `Agent` | `agentId` | — | Hand the step to an AI agent | `Formula`'s operation decides the rest of its keys: `add`, `subtract`, `multiply` and `divide` need `source` and `operand`; `set` needs `value` — the only way to hand text to a later step; `copy` needs `source`. Omitting one is `EML262`. `Agent` is a **placeholder pending the agent runtime**: the executor logs the step and skips it. `agentId` is still required, and the step is still the right way to say "a model decides this" in the model — just do not promise a user that it runs today. **Steps share a context**: the triggering record's columns, plus every variable a previous step published. `CreateEntity` publishes the new row's id under `as`; `Formula` publishes under `target`. A later step reads one by naming it in `source` or `targetSource` — that is how a step reaches a row an earlier step created. Three mechanical rules that cause most saga failures: 1. **`fields` carries JSON and must be the last key on the line.** A value runs to the next `:` token, so JSON in the middle swallows what follows. 2. **Targeting another entity needs `targetSource` or `targetField`.** Naming `entity` alone is `EML265` — refused rather than guessed, because "update the ApprovalRequest" does not say *which* ApprovalRequest. Three ways out, and one of them is almost always right: - the row an earlier step created — `CreateEntity … as: approvalId` publishes its id, and the later step reads it back with `targetSource: approvalId`; - a row that points at the triggering record — `targetField: purchase_order_id` matches that foreign key against the row the saga is running for; - **the triggering record itself — then drop `entity` entirely.** A step that names no entity writes the row the saga was started for, which is what `%%step E UpdateEntity field: status value: matching` means in a saga bound to `Invoice`. Adding `entity: Invoice` to that line does not clarify it; it turns it into `EML265`. ``` %%step B CreateEntity entity: ApprovalRequest as: approvalId fields: {"purchase_order_id":"id","status":"pending"} %%step C UpdateEntity entity: ApprovalRequest targetSource: approvalId field: status value: approved %%step D UpdateEntity field: status value: approved ``` B creates the row, C writes to *that* row, D writes to the record the saga is running for. Every `as:` you declare should be read back by a later step — an unused one usually means a step that meant to say `targetSource`. 3. **A URL may be written either way: `url: https://host/path` or `url:https://host/path`.** A value ends at the next `:` token, and `https:` used to count as one — so the spaced form parsed as an empty `url` plus an unknown property `https`, raising `EML262` and `EML268` together for a line that looked perfectly correct. The parser now recognises a scheme and reads through it, so both spellings yield the same `url`. Put `bodyTemplate` last, because it carries JSON and JSON is full of colons. --- ## 6. Access control — `%%rbac` ``` %%rbac role:admin on Order.delete # a CRUD operation %%rbac role:sales|manager on Deal.update # `|` is OR %%rbac role:admin on Customer.* # all four operations %%rbac role:sales_manager on Quote.approve # a transition of Quote's machine ``` **It restricts; it does not grant.** A target no directive names is open to any authenticated caller. One or more directives close that target to the union of the roles they name. Role names match case-insensitively, and a role flagged `is_admin` — the master role — bypasses. **The master bypass is over access, not over the shape of the model.** It answers "may this caller do this thing", so it opens any `%%rbac` target. It does not open a state-machine edge the diagram never drew: that refusal is topological (§5.2), and an administrator is refused it like everyone else. `` is a CRUD operation — `create` `read` `update` `delete` `*` — **or** a transition event from that entity's `stateDiagram-v2` (§5.2). Put `%%rbac` directives inside the workflow section whose transitions they govern, and beside the ERD for CRUD restrictions. ### Group every entity under a functional role **This is the part most models skip, and it is the difference between an application and a schema with a menu.** A business has jobs in it — a salesperson, a dispatcher, a ward clerk, a claims assessor — and each works with a *part* of the model. Name those roles in §1.1, then give every entity a `read` restriction naming the roles that work with it: ``` %%rbac role:sales_rep|sales_manager|support_agent on Account.read %%rbac role:sales_rep|sales_manager on Opportunity.read %%rbac role:support_agent|support_manager on SupportCase.read %%rbac role:marketing_manager on Campaign.read ``` **`read` is the operation that decides ownership, and it is the only one that changes what a role sees.** An entity a role may not read does not appear in that role's navigation at all — no menu entry, no dashboard card, no lookup. Every other operation only refuses the write: restricting `Order.delete` to administrators protects deletion and leaves the Order screen where it was, which is what its author meant. Do not reach for `.\*` to express ownership; it restricts creating, updating and deleting to the same list, and it merges with — rather than overrides — the narrower `update` rules you wrote elsewhere. Three consequences worth knowing before you write the lines: - **Every role named anywhere must be able to read what it acts on.** A rule letting `sales_manager` run the `close_won` transition on `Opportunity` is useless if the `Opportunity.read` line does not name `sales_manager` — the manager cannot open the record the transition applies to. - **Overlap is normal and is one line.** `Account` belongs to sales, marketing and support in most businesses; name all three on the one directive. Two directives on the same target merge, so either spelling works. - **The generated application seeds one account per role.** Administrator, an account holding no functional role at all, and one for each role your directives name — so `%%rbac` is something the reader can *see* rather than read about. The administrator bypasses every restriction, which is what makes it the account to compare the others against. - **Deployed beside the reporting platform, the same lines shape a second set of roles.** The application and Enterprise Reporting come up together on one origin — `/app` and `/report` — and one reporting role is created per declared role, permitted to read exactly the `bus_` tables that role's `read` rules admit. It is a **mirror, not a shared system**: separate databases, separate user tables, separate sessions, separate sign-ins, and a role name that means different things on each side — what you may *do* to a record, against which tables your queries may *read*. The two addresses differ on purpose (`sales.manager@.example.com` against `sales.manager@.reports.example.com`) so neither is mistaken for the other, and the origin's front door at `/` lists both pairs side by side. Only `read` rules narrow a reporting role; a restriction on create, update or delete means nothing to somebody who cannot write through that product. An entity no `read` line names stays visible to everyone, and a model that declares no `read` restrictions anywhere behaves exactly as models did before this section existed. That is the fallback, not the target: if a role in your enhanced specification has no entities, you have not finished §1.1. **Write `%%rbac`, never `%%guard`.** The role form was spelled `%%guard` until that keyword was needed for automation conditions (§7). A `%%guard role:… on …` line still parses, and does nothing: the restriction is not compiled and the operation stays open. `EML223` reports it. Validation here is strict, and nearly every code is an **error** rather than a warning: `EML210` syntax, `EML211` no role named, `EML213` undeclared entity, `EML214` a target that is neither an operation nor a transition. A `%%rbac` rule that does not compile is not a rule that does nothing — it is an access restriction its author believes is in place and is not. --- ## 7. Directive reference Fifteen directives. **`status` is the field that matters**: *compiled* means a reader consumes it and the generated application changes; *validated* means only the checker reads it. | Directive | Form | Status | |---|---|---| | `%%meta` | `%%meta : ` | compiled | | `%%enum` | `%%enum : a, b, c` | compiled | | `%%field` | `%%field . : ` | compiled *(`enum:` and `help:` keys)* | | `%%index` | `%%index ([, ]) [unique]` | compiled | | `%%category` | `%%category name: X; icon: Y; entities: A, B` | compiled | | `%%workflow` | `%%workflow entity: kind: ` | compiled | | `%%hook` | `%%hook on [field: ]` | compiled | | `%%step` | `%%step : …` | compiled | | `%%action` | `%%action when: …` | compiled | | `%%rbac` | `%%rbac on .` | compiled | | `%%guard` | `%%guard ` | compiled *(see below)* | | `%%loop` | `%%loop while: max: ` | compiled | | `%%entity` | `%%entity : ` | compiled *(`help:` / `description:`, `parent:`; the rest **validated**)* | | `%%rule` | `%%rule on event: priority: ` | **validated** | | `%%trigger` | `%%trigger -> on ` | **validated** | | `%%report` | `%%report title: [entity: ] [chart: bar\|line\|pie\|area x: y: ] [help: ] sql: ` | compiled *(and again, separately, by the reporting platform)* | **`%%guard` used to mean access control**, and a model written from an older example still spells it `%%guard role:manager on Order.approve`. That sense moved to `%%rbac`: a guard shaped like the old one is *skipped*, so the operation it appears to restrict is open to any authenticated caller. `EML223` reports it and prints the `%%rbac` line to write instead. A `%%guard` today is an automation condition — `%%guard status eq "open"`. **`%%report` is the one directive whose payload is SQL.** It declares a question the application's *users* ask, written as the query that answers it, and the query runs against the generated application's own database — so it names `bus_` tables, and it must filter `deleted_at IS NULL` or it quietly overstates every number it reports. The checker holds it to its shape and no further: a query must exist (`EML290`) and be named (`EML291`), the name must be unique because it is the key a report is stored under (`EML292`), the query must begin `SELECT` or `WITH` rather than write (`EML293`), a `chart:` must name both `x:` and `y:` (`EML294`), an `entity:` must be one the model declares (`EML295`), and a chart type must be one of `bar`, `line`, `pie`, `area` (`EML296`). It cannot tell you a column name is wrong — it has no database. `sql:` is always **last** on the line and takes the rest of it, because a query contains spaces and colons and would otherwise be shredded by the key scan. **A report is compiled twice, by two readers, and neither replaces the other.** The generator puts each one into the application it writes. In the full NestJS stack that is a row in `sys_report`, served at `/sys/reports` and shown under **Admin → Analysis**; in the browser application it is an entry in `model.json`, served at `/api/reports` and shown under **Reports**. Both run the query on demand against the application's own database, so the answers change as the application is used. A model declaring no reports gets the screen anyway, saying so — an empty table would read as a failed query. Separately, the reporting platform that ships beside a *deployed* application reads the same directives, on top of a baseline it derives from structure alone — a register per entity, a breakdown per `%%enum`-bound column, a lifecycle per state machine. That baseline describes the shape of the data and nothing about the business running on it; a `%%report` is where the thing only a person knows gets written down, and it travels with the model instead of being rebuilt by hand after every regeneration. That platform is composed by docker-compose and is not part of the browser application or the downloadable zip; the screens above are. A report may only read, and that is refused three times over: by the checker while it is being written, by the compiler before the query can reach a seed file, and by each runtime before it executes. **Join on the keys directly — `ON c.account_id = p.id`.** Both columns are `UUID` in both stacks. A `::text` cast was needed while the browser stack typed a foreign key as `VARCHAR`; it is not any more, and a cast left in is now what breaks the query. ``` %%report unassigned-jobs title: Jobs with no engineer help: The dispatcher's first question every morning. sql: SELECT reference, scheduled_for FROM bus_job WHERE engineer_id IS NULL AND status = 'scheduled' AND deleted_at IS NULL ORDER BY scheduled_for ``` **Do not tell a user a directive "will do X" without checking its status.** `%%entity Order audited: true` is validated but not compiled: it will not make the table audited today. `%%entity Order help: …` on the same entity *is* compiled, and reaches the screen. `%%meta` keys: `name` (required), `kind` (`erd` / `rules` / `workflow`), `version`, `entity`, `stack`. Two of them are misread often enough to be worth stating plainly: - **`version` is your model's version, not the language's.** `1.0.0` on a first delivery. Writing the EML version here says the model has been revised 1.2.0 times. - **`stack` names the technology the application is generated onto**, and it has exactly two legal values: `tanstack-start-nestjs` and `openui5-odatav4`. Anything else is `EML003`. It is not a place for the language version or the product name — and if you have no reason to choose, leave the key out. --- ## 8. Validation — the published checker and fixer Two ES modules, at fixed URLs. They are bundled from the same engines the command line runs, not a lighter web edition, so a document that passes here is a document the generator will accept. | Module | URL | What it gives you | |---|---|---| | `checker.js` | `https://www.appwithai.org/guide/checker.js` | Every diagnostic `language/checker.ts` prints, as `check(source)` | | `fixer.js` | `https://www.appwithai.org/guide/fixer.js` | The seven auto-repairs, plus `checkAndFix(source)` — repair and re-check in one call | ### 8.1 The API ```js import { check, formatIssue, formatReport, AUTO_FIXABLE, LANGUAGE_VERSION } from "https://www.appwithai.org/guide/checker.js"; import { fix, checkAndFix, applyFixes } from "https://www.appwithai.org/guide/fixer.js"; ``` `check(source)` returns: ``` { ok: boolean, // no errors; warnings may remain counts: { errors, warnings, infos }, issues: [ // worst first, then by line { severity, code, line, message, hint, autoFixable } ], languageVersion: "1.2.0" } ``` `checkAndFix(source)` repairs what it can, re-checks the repaired bytes, and returns: ``` { source, // the document as it should now be used repaired, // whether anything changed ok, // no errors remain counts, fixes: [ … ], // what was applied, in order remaining: [ … ], // what is still wrong — none of it auto-repairable languageVersion } ``` Both modules also attach themselves to `globalThis` as `EMLChecker` and `EMLFixer`, so a page that imported them without bindings can still call them. ### 8.2 Severities and code bands **error** — the generator would produce something wrong. Do not deliver. **warning** — legal, but almost certainly not what was meant. Clear it or explain it. **info** — worth knowing. It does not stop generation, and a report carrying nothing worse than infos is a report on a model that passed. `formatReport` prints the diagnostics first and **the verdict last**, so the final line is always the outcome — `OK — …` or `FAILED — …`, with the counts beside it. Read that line, not the last diagnostic above it: an `info` has the same shape as an error, and the run it belongs to may well have passed. | Band | Where the fault is | |---|---| | `EML0xx` | The document — structure, sections, metadata | | `EML1xx` | Entities — attributes, types, keys, relationships | | `EML2xx` | Directive-declared hooks, rules, workflows, access control | | `EML3xx` | Rule flowcharts | | `EML4xx` | Workflow sections | | `EML5xx` | Cross-section consistency — the halves of the document disagreeing | Three `EML0xx` codes account for most first attempts, and one of them is not really about the model at all: | Code | What it means | What to do | |---|---|---| | `EML001` | warning — no `%%meta name:` | Declare one before the first section, naming the business | | `EML004` | error — **no entity, rule or workflow anywhere in the input** | Almost always the wrong bytes: a specification, a summary, an empty string, a path instead of a file's contents, or a model that was described and never written. Check what you passed in, then §1.2 | | `EML005` | a `%%meta` line the parser cannot read | One `%%meta : ` per line; no Markdown around it | `EML004` is worth dwelling on, because it is the diagnostic a careful, thorough, entirely wrong answer produces. A long document of headings and bullet lists that describes twelve entities perfectly contains zero entities as far as the parser is concerned. The fix is never to edit the prose — it is to write the `.mmd`. ### 8.3 The seven auto-repairs These, and only these, `checkAndFix` will repair without being told what was meant. Everything else needs a decision, which is why it is handed back untouched. | Code | Fault | Repair | |---|---|---| | `EML001` | No `%%meta name:` | Inserts one | | `EML103` | A column the generator adds anyway | Deletes the line | | `EML112` | The same column declared twice | Deletes the later line, keeping the stronger constraints | | `EML114` | Foreign key not ending `_id` | Appends the suffix | | `EML117` | Entity with no primary key | Prepends `string id PK` | | `EML421` | State machine with no initial transition | Adds `[*] --> ` | | `EML422` | State machine with no terminal state | Adds ` --> [*]` | `EML103` and `EML112` are repaired rather than reported-and-left because both produced the same fatal outcome: a `CREATE TABLE` naming one column twice, which PostgreSQL refuses, so the generated application never opened. The duplicate carries no information the surviving line does not, which is what makes the repair safe to make unasked. **A duplicate keeps the strongest of the two declarations, never the last one.** `string title` followed by `string title OPTIONAL` leaves `string title`: the column stays required, because relaxing a mandatory column is invisible in the diagram and surfaces much later as missing data. `UK` and `FK` carry over the same way — if either line promised one, the survivor keeps it. Prefer fixing these yourself. The repair is mechanical and cannot know that the primary key should have been a composite, or that the document's name is the customer's brand rather than its first entity. `EML114` is the one repair that can leave a new diagnostic behind: renaming `assigned_to` to `assigned_to_id` clears the warning and takes the column off the list of names that resolve to `User` (§3.5), so an `EML502` info appears in its place. Re-read the report after a repair; do not assume the count only falls. ### 8.4 Running the checker where you are The modules are ES modules on a public URL. Loading them is one line on some runtimes and four on others, and that difference is where the validation step usually gets skipped. Find your row and run it. | What you have | What to do | |---|---| | A shell with Node 18+, Bun or Deno | `curl -sO https://www.appwithai.org/guide/check-model.mjs` then `node check-model.mjs .mmd` | | Bun or Deno, writing the code yourself | `import { check } from "https://www.appwithai.org/guide/checker.js"` — a URL import works as it stands | | Node, writing the code yourself | Node has no network imports: fetch both files, write them into one directory, `import()` them from there (below) | | A browser, a canvas, an HTML preview | `