Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / Security

what a refused user is told

app.DenialMessage = "You do not have permission to {verb} the {entity}.";

When a write is refused, two different sentences are produced from it. The person using your app gets a short one that names the operation and the thing and no more — never the rule, the predicate, or their own address. Everyone building the app — `osy test`, `osy run`, `osy logs` — gets the whole story: entity, verb, the rule as you wrote it, and who was asking. You write the first one, in your own words: once for the whole app with `app.DenialMessage`, per entity with `security { message "…"; }`, or per condition on a `deny` rule.

stable4 examples compiled by CIsecurityauthorizationdiagnosticsmessages

Summary#

A refusal is read by two audiences with opposite needs, so the platform writes it twice.

WhoSeesWhere
the person using your app"You do not have permission to change this policy."the 403 body, the error toast, a REST or MCP error
whoever is building itthat sentence plus entity, verb, the rule as you wrote it, and the callerosy test, osy run, osy query, osy logs

You never choose between them and you never wire anything up. What you do choose is the first sentence — the one your users read — and the usual way to choose it is one line for the whole app:

app.DenialMessage = "You do not have permission to {verb} the {entity}.";

[Principal] entity Member { [MaxLength(200)] string Email; }

That sentence is now what a refused person reads anywhere in the app, with {verb} and {entity} filled in per refusal: "You do not have permission to update the cost center." An entity that needs different words says so itself; a single condition worth explaining says so on its own rule. Those are the exceptions, in that order.

This is about WRITES — create, update, delete — and reads are not missing from that list. A read you are not permitted is not refused, it is filtered: the row is simply not in the result. There is no denial, so there is nothing to say. See Why reading is not here {#reads}.

Signature#

app.DenialMessage = "…";                     // the whole app. One line, usually the only one you need.

security {
  message "…";                               // this entity, when the app's sentence is not right for it
  deny <ops> [when …] [where …] message "…";  // this CONDITION, when the condition is the person's situation
}

Most specific wins. A deny rule's own message beats the entity's, which beats the app's, which beats the platform's built-in sentence.

The holes a message can carry#

HoleBecomesExample
{verb}create, update or delete"you cannot update …"
{Verb}the same, capitalised"Update is not allowed …"
{entity}the entity's name read back as EnglishCostCenter → "cost center", HTTPEndpoint → "HTTP endpoint"
{Entity}the same words, sentence-casedCostCenter → "Cost center"

Write {{ for a literal brace. Anything else in braces is a compile error naming this whole list, so a typo never reaches a real person as {entty}.

The article is yours. The platform's own sentence says "this cost center" rather than "a cost center" because no rule picks a/an correctly — a vowel-letter rule writes "an user", a consonant one "a invitation". Your app knows its nouns, so write "the {entity}", "a {entity}", or drop the article entirely.

Description#

One sentence for the whole app#

app.DenialMessage is a top-level line in any model file. It is the whole surface most apps ever need: the platform's built-in wording is nearly right already, and what an app actually wants is to say the same thing in its own voice, once.

entity CostCenter {
  [MaxLength(100)] string Title;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete when IsAuthenticated;
  }
}

With the app.DenialMessage above, a refused write on CostCenter reads "You do not have permission to update the cost center." — for every verb, and however the refusal was reached.

However it was reached is the part that matters, because there are two ways and an author should not have to know which one they hit:

  • the row satisfied none of the allow rules ("this row is not yours"), or
  • no rule for that verb is active for this caller at all ("nobody in your position may do this").

Both get your sentence.

When one entity needs different words#

Put a message at the top of that entity's security { } block. It overrides the app-level sentence for this entity only, and takes the same holes.

entity Policy {
  [MaxLength(100)] string Name;
  Member Owner;
  security {
    message "Only an organisation admin can {verb} an approval policy.";
    allow read when IsAuthenticated;
    allow create, update, delete where Owner == user;
  }
}

Reach for this when the app-level sentence would be misleading or unhelpfully vague for one thing in particular — a screen users hit often, a rule that surprises people, a noun the generic sentence reads badly around. If you find yourself writing near-identical messages on entity after entity, that is the app-level line asking to be written instead.

When a single condition is the whole story#

A message on a deny rule replaces the sentence for that rule only, and only when that rule is what refused. Use it where the CONDITION genuinely is the person's situation and knowing it is what they need:

entity Report {
  [MaxLength(120)] string Title;
  Member Owner;
  bool Locked = false;
  security {
    message "Only the owner can {verb} a report.";
    allow read when IsAuthenticated;
    allow update where Owner == user;
    // The one case that is not "you are the wrong person" — it is "this is the wrong time".
    deny update where Locked message "This report is locked while it is being paid. It reopens once payment clears.";
  }
}

Someone editing a locked report is told about the lock. Everyone else refused an update on a Report is told "Only the owner can update a report."

This is the narrow tool, not the general one — and a message on an allow is a compile error. A predicate says who QUALIFIES, from the system's point of view; it never becomes a description of the person's situation however you word it. And the commonest refusal of all is made by the ABSENCE of a matching rule — several allows were consulted and none matched — so no individual rule's message would even be true. That is what the entity-level and app-level messages are for.

Why reading is not here#

There is no way to write copy for a refused read, and that is a design decision rather than a gap.

Reading is filtered, not refused. A query returns the rows you may see; the rest are not in the result. There is no denial, no 403, and nothing that happened for a sentence to describe — so a message would have nowhere to appear and nothing to be about. You do not see what you are not allowed to.

What your users read when you have written nothing#

The built-in sentence, built from the operation and the entity's own name read back as English:

OperationSentence
createYou do not have permission to create this cost center.
updateYou do not have permission to change this cost center.
deleteYou do not have permission to delete this cost center.

When the refusal names no entity at all, it is "You do not have permission to do that."

Nothing else ever appears there. Not the rule, not the predicate, not the name of a policy, not the caller's own email address. That is deliberate on both counts: a denial message is exactly where somebody probes, so the rules are not printed at it; and a sentence naming an internal is not copy your users should ever have been shown.

It does not narrate the rule either. "You must be an admin of this organisation" would be friendlier and it is still the rule, in nicer words. What a person may DO about a refusal is your app's to say — which is what everything above this section is for.

Where the whole story is#

Writing friendly copy never costs you the ability to debug your own app. Every refusal is recorded server-side in full, at Warning, with the entity, the verb, the caller and the request's correlation id as fields — so a person who reports "it says I don't have permission" hands you the correlation id from the error they saw, and one command shows you exactly which rule refused them:

osy logs --correlation <id>

The same detail is inline in the places you are already looking while you build:

  • osy test — a denial that escapes a test uncaught reports both halves.
  • osy run, osy query — you are driving your own app as its author, so you get both halves.
  • osy explain — reads the rules directly; see security { }.

⚑ A refusal is very often the security model working exactly as it should — a screen offering a button it should not, a probe, a grant that has lapsed. That is why it is recorded at Warning rather than as an error: a log where every correct refusal looks like a fault is a log where the real fault is invisible.

See also#

Related

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…

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…

acting for another principal

A shared terminal, a kiosk, a scanner or a back-office integration is a **user of your app**, not a mode of it. It…

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…

Seeing what happened when your code ran

Opens a finished run and shows what it did — each step named and pointed at its source line, the values that crossed…