Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Local

Checking your app

osy lint [path] [--json] [--strict]

Checks your app against production best-practice rules and reports where it falls short — the maturity signal, beside the correctness ones. Covers security, the tests that prove it, the data model, the cost of your queries, and what happens when a remote call fails.

previewlocalcliauthoring

Summary#

Reports where your app falls short of a production-grade bar. Compiling tells you the app is correct; osy lint tells you whether it is finished — starting with the category that hurts most when it is not: security.

Signature#

osy lint [path] [--json] [--strict]

Description#

Findings come in three tiers:

  • MUST — a production app is broken or exposed without it. Every rule at this tier compiles and type-checks — which is exactly why they need a linter. Most are security; the other is a number that comes back wrong.

    • A total that is quietly short. A function creates rows, does not commit, and then sums, averages or takes the min or max over that entity (Sum / Average / Min / Max / Count). The database computes the aggregate over committed rows only, so the answer silently omits the rows just created — it is short by exactly the work the function just did. Nothing fails: the code compiles, runs, and hands back a wrong number. Call UnitOfWork.Commit(); before the aggregate.

    • A query that filters on an edit you have not committed. Change a property on a row, do not commit, then query that entity with a filter on the property you changed. The filter runs in the database, against the committed value — the one from before your edit. So the query misses the row your edit would now match, and still returns the row it no longer matches; and that row then reads back with your new value, contradicting the very filter that selected it. Commit before the query, or filter the rows you already hold in memory. (Reading the value straight off the row you edited is fine — that always shows your edit. It is only the filter that is computed on the old value, and only for a property you actually changed.)

    • The login nobody tests. The app has an [AuthMethod] and no test proves it both ways — that a right credential is accepted and that a wrong one is refused. Every access rule you wrote sits behind this one function, and it is the one that fails invisibly: a login which handed a ticket to anybody passes a suite that only signs in successfully, and the app behaves exactly as it does now until the wrong person is holding a ticket. Test the refusal too — and include an address with no account, which must fail exactly like a wrong password, or the form tells an attacker which addresses are registered.

    • A credential is handed out. An entity grants reads and a sensitive field (PasswordHash, *Token, *Secret) has no field-level deny read, so it goes to everyone who can read the row.

    • A role grant can be written by its own subject. A role grants (and the first admin) table whose writes are ungated — or guarded by a where row-filter, which here says "you may write your own privileges" — lets a caller hand themselves whatever role the rest of the app trusts. Every other rule you wrote is then decoration. The same hole is reported when someone may update or delete a grant they could not have created: editing a Member grant into an Admin one is the identical escalation through another verb.

    • The auth flow is denied its own credential. An [AuthMethod] runs as the ephemeral auth principal, which bears a role and has no user — working out the user is what it was called to do. An entity it must read, granted only by a where row-filter, therefore denies it: there is no user id to match, so the login cannot read the record it exists to check and fails every time. Your tests still pass, because they run as a real signed-in user for whom that filter works perfectly (auth bootstrap (login, before anyone is signed in)).

    • A secret is compared with ==. storedHash == Security.HashPassword(password) is not merely insecure — it can never be true, because a hash is salted; the login cannot succeed (use Security.VerifyPassword, Security.* — hashing, verifying, tickets, random ids). And == on an HMAC tag leaks, through how long the check takes to fail, how much of a guess was right — enough to forge one (use Crypto.FixedTimeEquals, Crypto.HmacSha256Hex and Crypto.FixedTimeEquals).

    • An integer-only format on a Double. someDouble.ToString("X") or "D" — those specifiers are integer-only, so .NET throws a FormatException at runtime. It compiles (ToString takes any string), so only the crash tells you. Format the Double with N/F/C/P, or convert to an integer type first.

  • SHOULD — expected, and worth flagging.

    • A login that says WHICH half of the credential was wrong. Answering "no account with that address" and "wrong password" differently lets anybody discover which addresses are registered, without ever guessing a password — the input to credential-stuffing and to targeted phishing. It never looks like a security decision while you are writing it; it looks like a helpful error message. Answer every rejection identically, and put the specific message where it is safe to be specific: the sign-up page, or a reset flow that emails the address rather than telling the browser.

    • An entity with no security { } block is safe (deny-all means it grants nothing to anyone) but is usually a grant someone forgot to write — the app cannot read its own data.

    • A credential written to the log. Log.*(… someHash, someToken …) — a log line is not private (it ships to a sink, is retained, often indexed), and nothing redacts it for you. Log an id or an email that identifies the record, never the credential field itself. (Only fields derived as a credential or ending in Hash/Token/Secret/… are flagged — an innocently-named TokenCount is left alone.)

    • An app built to be multi-user, with no way to log in. It declares a [Principal], a [Role] enum, and a gated surface — a secure-by-default page or a role-grant table — so it plainly means to have users; but not one function is an [AuthMethod], so there is no login path at all. Nobody can authenticate, so nobody can pass the deny-all gate those pages and grants sit behind: the app compiles, its data model is complete, and not one real user can get in. Add a login [AuthMethod] and wire it in app.AuthBootstrap (auth bootstrap (login, before anyone is signed in)). A fully public app — no [Principal] — is a valid choice and is never flagged; nor is a [Principal] modelled as plain data with no roles or gated pages yet.

    • A rule nobody ever tried to break. An entity whose rules can deny, and no test acting as a principal proves they do; or an invariant no Assert.Throws proves bites. A rule you have not tested is a rule you only believe you wrote — and an untested invariant does not fail loudly when it rots: the day someone deletes it to make an import work, the suite is still green. A [Test] that asserts nothing at all is reported for the same reason.

      The two refusals are proved differently, and the difference matters. A denied write throws, so Assert.Denied settles it on its own. A denied read does not throw — row security is part of the query, so the row was never in the result set — and you prove it with an empty result. But an empty result is evidence of a denial only if there was something there to deny: on its own it passes just as happily when the rule refuses everyone, when the table is empty, or when the row was never created. So pair it — show that somebody can see a row of that entity, right beside the principal who cannot. Unpaired, the assertion is reported, because it does not yet prove what it appears to.

    • A rule that cannot say why. An invariant or a [Pattern] with no message refuses the user with the rule itself — and a pattern's rule is a regular expression, which explains nothing.

    • An unbounded string. No [MaxLength] means the caller decides how much you store. Fine for prose; wrong for a code, a name or a status.

    • An enum value that reads as a run-together word. Without a [Label] label, an enum member is shown by its own name — perfect for Draft, and wrong the moment there are two words: the grid cell says "InProgress". Only multi-word members are reported; a single-word one needs no label.

    • Work that only shows up on real data. A query materialized with no Take fetches however many rows happen to exist; a Skip with no OrderBy lets page 2 repeat a row from page 1; reading a child collection in a loop over parents is one query per parent; and the same query run twice does the work twice — and may give two different answers. All four are correct, fast on a laptop, and the reason an app that worked in development falls over in production.

    • A remote call that assumes it works. An outbound call (Http.*) has two failure modes and they need two different answers. The network throws — a timeout, a DNS failure, a refused connection — and with no try that kills the function outright, so the caller sees an internal error instead of the failure you meant to handle. The response does not throw: a 404 or a 500 comes back as an ordinary result, so code that never looks at IsSuccess carries on and uses the error page's body as though it were the answer — wrong data, no stack trace, no log line.

    • A function that reaches the network through something it calls. The call may be nowhere in the function's own text — a helper makes it — and the function still has no try. A timeout down there kills this one exactly as dead. Nothing you can read in it warns you, which is the whole reason the linter looks past the source and at what the code actually does.

    • A catch that says nothing. A caught exception with no log is a failure the app decided to survive and then forgot. In production it is invisible: no trace, no count, and no way to answer why the numbers are off.

  • CONSIDER — a candidate for your judgment, reported with its evidence. Never an error.

    • A [Unique] or [Pattern] field with no [Required]: every constraint except [Required] lets null through, so two rows may both have no value and not collide. If the field is genuinely optional that is exactly right — and if you read [Unique] as "every row has one", it does not say that. Only you know which was meant, so the linter asks rather than asserts.
    • A routed page with no title. A page declares its name with [Title("…")] (the chrome/route name a breadcrumb reads) or a meta { title = "…"; } block (the SEO <title>). A routed page with neither is a nameless browser tab and an accessibility gap — a screen reader announces a page by its title on navigation. A title-less route can be deliberate (a redirect-only page), so it is a candidate, not an error.
    • An editable field under a rule the browser can't pre-empt, in a form that catches nothing. Plain field rules ([Required]/[MaxLength]/[Pattern]/…) are surfaced for you — the input carries them as native attributes, so the browser blocks bad input and paints the invalid state without any app code. Two rules can't work that way and still throw a ValidationException on save: a cross-field invariant (Paid <= Total), which the client can't evaluate; and [Unique], enforced by the atomic DB index — a client "is this taken?" check races with the index, so the check is a UX nicety and the catch is the real guard. If a form edits such a field and catches no error, the user sees a raw failure — catch it where the form saves and show the message. If the component handles errors at all, it is not flagged.
    • A class method that quietly hands off to the server. A class method is client-runnable code, so a call it makes to a function or method that runs on the server is a network round trip — and nothing at the call site shows it; whether the callee stays on the client is a fact about its body, not the call. Now that almost everything runs on the client, a server hop is the notable exception. If it is intended, leave it; if the method was meant to stay client-side, keep the server-only work off its path.
    • A number/date format that runs on the server. value.ToString(format[, culture]) formats in the browser only for the specifiers the client reproduces byte-identically; anything else fails closed to the server — a round trip, invisible at the call site. A Double outside N/F/C/P, a custom pattern over a Double (which also rounds differently), a runtime-built format or culture, an unsupported specifier under a culture, or an unsupported date format all round-trip. The finding names which, and the fix (switch the specifier, use a Decimal, make the format or culture literal). If the round trip is fine, leave it.

Each finding names the rule, what it is about, and how to close it.

--strict makes any MUST-tier finding fail the run, so it can gate a ship. --json writes the findings as JSON, for a coding agent or a CI step.

This is a growing rule set, not a finished one — rules are added as patterns emerge. To see what the app is rather than what it is missing, use Understanding your app.

Examples#

osy lint                  # what this app is missing
osy lint --strict         # fail the run on any MUST-tier finding
osy lint --json           # findings as JSON

See also#

Understanding your app — the resolved model: what the app is.

Explaining your app's security — who can do what, in plain English; --with-findings folds these findings into it.

secure by default (deny-all) — the deny-all default the security rules are written against.

security { } — how to declare an entity's access rules.

Related

Understanding your app

Prints your app's RESOLVED model — field types bound to real types, relations wired to the entity they target, each…

Explaining your app's security

Explains your app's declared authorization in plain English — who can read, create, update and delete each entity…

Compiling your app

Compiles your app's source into the app on the local platform — the inner-loop compile-and-apply. It applies additive…

secure by default (deny-all)

Deny-all is the posture, and it is the only one: an entity that declares no `security { }` block is denied to every…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

constraints

The per-member rules the database enforces — Required, Unique, MaxLength/MinLength, Min/Max, Pattern, and the…

invariant

A row-level rule spanning several members, checked when the row is written. Use it when a constraint on one member is…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

Sum / Average / Min / Max / Count

Fold rows down to a single number — a query or a `List<T>` you already hold. The one thing to know before you use them:…

Http.*

Make an outbound HTTP call to a URL you build at runtime — a webhook, a third-party API, a discovered endpoint…

Log.*

Write a line to your app's structured log. Both a plain template (`"Order {OrderCode} shipped"`, order.Code`) and an…