Summary#
A refusal is read by two audiences with opposite needs, so the platform writes it twice.
| Who | Sees | Where |
|---|---|---|
| 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 it | that sentence plus entity, verb, the rule as you wrote it, and the caller | osy 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#
| Hole | Becomes | Example |
|---|---|---|
{verb} | create, update or delete | "you cannot update …" |
{Verb} | the same, capitalised | "Update is not allowed …" |
{entity} | the entity's name read back as English | CostCenter → "cost center", HTTPEndpoint → "HTTP endpoint" |
{Entity} | the same words, sentence-cased | CostCenter → "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
allowrules ("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:
| Operation | Sentence |
|---|---|
| create | You do not have permission to create this cost center. |
| update | You do not have permission to change this cost center. |
| delete | You 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#
- security { } — the
security { }block these messages live in - secure by default (deny-all) — why an entity you say nothing about refuses everyone
- runas — becoming the refused user, which is the only way to know a rule works
- Seeing what happened when your code ran — following a correlation id back through the whole request