Why Osy# · Chapter 01 · Written by agents
A coding agent can ask the toolchain instead of inferring.
The cost of a language was never its syntax; it was everything that accumulated around one and lived in people. More of your code is now written by something that cannot ask those people. So the toolchain answers what they would have: what the application is, who may read what, whether a change is right, and what actually ran.
ask the compiler$ osy model --json // what the app IS — resolved, not inferred $ osy explain // who may read what, from the declarations $ osy inspect // what actually ran, end to end, step by step $ osy check // one verdict — and a refusal carries its fix > Posture: deny-all. An entity with no `security { }` block grants no access to anyone.
01
Much of what a program means is not in its source.
It is in the people who wrote it, and a coding agent cannot ask them.
Which of these forty tables the invoice one really is. That the access check three files away is
why this query returns nothing. That Amount went nullable because of a migration in
2023. None of that is written down. All of it is load-bearing.
A new colleague learns it by asking. An agent has none of it, and it will not come back and ask; it will produce something plausible instead. So the useful question is not whether the model is good enough. It is how much of what the application means is explicit enough to be asked for, and what answers when it is.
In Osy# the data, its rules, the functions, the screens and the processes are one program, so the compiler holds what a colleague would have held. The four verbs below are how a reader asks it.
Everything below is the ordinary toolchain. Not a plugin, not an MCP server, not a prompt.
02osy model
Ask what the app is.
One command, and the whole resolved model — not the files, the model the compiler built out of them. No database, no server.
$ osy model --json { "summary": …, "capabilities": …, "entities": [ … ], "provided": …, "providedEnums": …, "enums": …, "functions": …, "workflows": …, "clients": …, "components": …, "themes": …, "topics": …, "apis": … } // one entity out of that, verbatim { "name": "Invoice", "kind": "entity", "fields": [ { "name": "Amount", "type": "decimal", "required": true }, { "name": "Owner", "type": "User", "relation": { "target": "User", "kind": "reference" } } ], "security": { "posture": "deny", "rules": [ { "effect": "allow", "operations": ["read"], "hasRowFilter": true } ] }, "platformFields": ["Id"] }
Every field with its real type. Every relation wired to its target. Each entity's security posture as the compiler computed it. Without this an agent greps for a class name, reads three files, and infers. Inference is where the wrong answers come from.
03osy explain
Ask who may read what.
Derived from the same declarations the runtime enforces, so the report cannot say something the runtime would not do. A document written by hand can; this one is not written.
entity Invoice { [Required, MaxLength(80)] string Reference; [Required] decimal Amount; User Owner; security { allow read where Owner == user; allow create, update where Owner == user; } } [Principal] entity User { [Unique, MaxLength(200)] string Email; [MaxLength(200)] string PasswordHash; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; deny read PasswordHash when !IsAuthenticator; } } // RoleGrant declares no security block at all. entity RoleGrant { User Grantee; [Required] AppRole Level; }
$ osy explain # Security report — who can do what > Posture: deny-all. An entity with no `security { }` block grants no access to anyone. ## `RoleGrant` — deny-all > No `security { }` block, and deny-all is in force — this entity grants NO access to anyone. - Read — No one (denied by default). ## `Invoice` — default-deny - Read — Only their own rows (where Owner is the caller). - Delete — No one (denied by default). ## `User` — default-deny - Read — Any signed-in user; or the caller holds the Authenticator role — `PasswordHash` carries a rule of its own. Fields - The PasswordHash field is withheld from a reader unless the caller holds the Authenticator role.
04osy validate
A refusal that carries its own fix.
Error messages are the largest surface an agent reads. They were written as such — because a diagnostic that names a line and stops is a search, and a search is a wrong turn waiting to happen.
// Invoice.Amount is a decimal, and Max over NO rows has no value // to answer with — so this returns null through a `decimal`. decimal biggest() { return Invoice.Max(i => i.Amount); }
$ osy validate model/bad.osy:1:39 ERROR RESOLVE_ERROR this function returns 'decimal', which cannot hold null — but this is the result of a `Max(…)` aggregate, which answers null over NO ROWS — `Min`/`Max`/`Average` have no zero to fall back on, where `Sum` and `Count` answer 0, and nothing here says what to return when the value is absent. Answer for absence at the `return` — `?? 0m` if that is what absence means here — or declare the function 'decimal?' and let it be absent. → for more, RUN: osy docs query-aggregates
Three things there that an ordinary error does not have: why the rule
exists, both remedies rather than the first one, and the contrast that makes it stick —
Sum and Count behave differently, and now you know that too.
What a guess cannot turn into
| You do this | Verdict | Because |
|---|---|---|
A Max(…) returned through a non-nullable decimal | refused | It answers null over no rows. Refused in every position where that null could escape — the local, the column, the return. |
new List<string> { "a", "b" } | compiles | Ordinary C#, and it compiles. The language is not a subset whose edges you have to learn first. |
| A stdlib call with no SQL form, inside a query | refused | It would work in memory and throw against the database. A MUST-tier finding, so osy check fails before you run. |
int x; read on a path that never assigned it | refused | Definite assignment, as in C#. The value that would have been null cannot be observed. |
And the whole of it survives into --json,
message and doc topic included — so the loop does not have to scrape a terminal.
$ osy validate --json { "success": false, "files": 3, "errors": [ { "file": "model/bad.osy", "line": 1, "column": 39, "code": "RESOLVE_ERROR", "message": "…", "docTopic": "query-aggregates" } ], "warnings": [], "symbols": { "entities": ["Invoice", …] } }
05osy inspect
Ask what actually ran.
A compiler can only tell you about the program. The other half of debugging is the run — and that is the half an agent normally cannot see at all, so it re-reads the code and guesses again.
// The test, and the function it calls. Three parameters, two locals — // which is what makes the trace below worth reading. [Test] void a_rate_is_applied() { Assert.Equal(12.35m, applyRate(49.4m, 0.25m, "quarterly")); } decimal applyRate(decimal amount, decimal rate, string note) { var scaled = amount * rate; var rounded = Math.Round(scaled, 2); return rounded; }
$ osy trace start --full $ osy test $ osy inspect ╭───────────┬───────────────────┬───────────┬───────┬──────────╮ │ trace │ function │ outcome │ steps │ started │ ├───────────┼───────────────────┼───────────┼───────┼──────────┤ │ e9b6cab1… │ a_rate_is_applied │ Completed │ 7 │ 15:32:00 │ ╰───────────┴───────────────────┴───────────┴───────┴──────────╯ $ osy inspect e9b6cab1 0 server stmt a_rate_is_applied 1 server eval applyRate amount = 49.4 rate = 0.25 note = "quarterly" 3 server stmt applyRate scaled = 12.350 4 server return applyRate scaled = 12.350 rounded = 12.35 5 server stmt a_rate_is_applied $ osy inspect e9b6cab1 --step 3 // the STACK at that moment, innermost first server applyRate scaled = 12.350 server a_rate_is_applied → applyRate amount = 49.4 rate = 0.25 note = "quarterly" server a_rate_is_applied (no locals in scope)
The values, not just the shape. Every step names the function it ran in
and carries the locals in scope; --step unrolls the whole stack at that moment, frame by
frame, with the arguments each call was made with — a_rate_is_applied → applyRate reads
as what it is. That is the difference between “the function was entered” and “the function was
entered with 0.25”, which is usually the entire question.
Server and client under one correlation id. And a fault is captured
whether recording was on or not — the thing you most need to see is the thing you were least likely
to have prepared for. --fault jumps straight to the throw site.
Four verbs, and the app has stopped being a guessing game. Now make being wrong cheap.
06
And the guess that goes wrong reaches nothing.
An agent will sometimes be wrong. The important question is what being wrong is allowed to do.
Everything above makes it right more often. This bounds what happens when it is not. In Osy# access
is declared on the entity and the default is deny: an entity with no security { }
block grants nobody anything, and the empty state is the safe one. The rule is compiled into every read
and every write the language can express, underneath whatever the agent wrote, so there is no check
to forget because there is no check to write.
The shape of a mistake
| You do this | Verdict | Because |
|---|---|---|
An agent adds an entity and writes no security block | compiles | It compiles, and grants nobody anything. The mistake produces an entity nobody can read, not exposed data. |
| An agent writes a query and forgets to filter by owner | compiles | It compiles, and returns only the caller's rows. The owner rule is in the compiled statement whether the query mentions it or not. |
| An agent writes code to get around the rule | refused | There is nothing to write. The rule is not a call the program makes; it is inside every statement the language can express. |
| An agent reads a password hash to "verify" something | compiles | It compiles and runs, and the hash is not there: a field the caller may not read is left out of the statement, so the value is absent rather than exposed. |
So it is right more often
It can ask what the application is, who may read what, and what actually happened, and get an answer derived from this application rather than inferred about it.
So being wrong is bounded
The blast radius of a bad guess is set by a declaration, not by whether the guesser remembered to check. Nothing it can write reaches data the declaration did not open.
Either one alone gives you a fast way to make a mess, or a safe way to be useless.
07osy check
The loop, in full.
Shorter than this page makes it look.
$ osy init --agent claude // scaffolds CLAUDE.md + settings $ osy model --json // what am I working on $ osy docs <anything> // how is this spelled … write the code … $ osy check // validate + lint + test — ONE verdict $ osy inspect --fault // when it ran, and was wrong
osy check is one command and one answer, which is the shape a loop
wants: not three whose output has to be reconciled. Nearly all of it answers --json.
And osy docs answers how a thing is written, from the reference, by name, alias or plain
question, so the vocabulary comes from the toolchain rather than from reading the compiler's
source.
The lint half of that verdict is 175 rules, each with a name that says what it catches — the catalogue is generated from the compiler's own registry, and the same page shows what the rules find in every sample app this site ships.