# Demeanor rule reference

Machine-readable reference for authoring Demeanor obfuscation rules.
Human-oriented version: https://www.wiseowlsoftware.com/docs/rule-authoring/

## What a rule does

Demeanor renames types and members to short meaningless names. Some names are
load-bearing at run time — a serializer resolving a property by name, a
framework routing to a controller by name — and renaming those breaks the
application. A rule identifies such symbols so Demeanor keeps their names and
renames everything else.

Renaming is not the only protection. A type whose names are frozen still
receives string and constant encryption, control-flow obfuscation and call
hiding.

## Where rules live

```
<project>/.demeanor/
  patterns/*.json     project rules, committed with the code
  decisions.json      resolved needs-decision answers, committed
```

Both are read on every run: the CLI, the MSBuild integration, and the MCP
server. Only the top level of `patterns/` is read; subdirectories are ignored.
There is no per-user or machine-local rule location.

## Rule schema

Every field below is required unless marked optional.

| Field | Type | Value |
|---|---|---|
| `id` | string | kebab-case, unique across the catalog. Prefix it (team or module) so it cannot collide with a builtin. An id equal to a builtin's shadows that builtin. |
| `version` | number | `1` |
| `predicate-vocab` | number | `1` |
| `kind` | string | `type-protection` for the ordinary case. `feature-exclusion` holds back one protection other than renaming. `aot-json-advisory` and `minimal-api-advisory` are advisory-only kinds. |
| `summary` | string | One line. Appears in audit output. |
| `reasoning` | string | Why the match must keep its name. Minimum 40 characters. Rejected if it contains IL or SDK jargon — it is read by a person deciding whether to act. |
| `remediation` | string | Optional for `auto-protect`. **Required** for `needs-decision` and `informational`, because both ask a person to do something. |
| `severity` | string | `auto-protect` \| `needs-decision` \| `informational` |
| `provenance` | string | `project` for a file under `.demeanor/patterns/`. The loader overwrites this with the actual layer regardless of what you write. |
| `license` | string | `any` \| `enterprise` |
| `when` | object | The match. See below. |
| `applies-when` | object | Optional. The configuration this rule is about: `{ "setting": "<name>", "at-least": "<level>" }`. Omit for a rule that applies everywhere. |
| `then` | object | The action. `{ "freeze": "<action>", "report-as": "<id>" }`, or `{ "exclude-feature": "<feature>", "report-as": "<id>" }` on a `feature-exclusion` rule. `report-as` must equal the rule's own `id`. |

Optional beyond the table: `reasoning_detail` (long-form background, surfaced
on request rather than at decision time) and `evidence_il` (IL-level detail,
which `reasoning` may not contain).

Fields that do **not** exist: `title`, `match`, `rationale`,
`implementsInterface`. An unrecognised key is **ignored, not rejected** — nothing
checks your spelling against this table, so a misspelled optional field simply
vanishes.

The one case that used to be silent no longer is: a rule whose `when` clause ends
up empty — because the key was written as `match`, or misspelled, or omitted — is
**rejected at load** with a message naming the file and the rule. It previously
loaded, validated clean and matched nothing. Every other misspelling is still
yours to catch: dry-run the rule and confirm it matched what you expected.

## Severity

| Severity | Behaviour |
|---|---|
| `auto-protect` | The match is protected silently. Use when the consequence of being wrong is a run-time failure and the rule has no plausible exceptions. |
| `needs-decision` | The match is **protected**, and reported as awaiting a human answer. It is never renamed on the strength of silence. Answers are recorded in `.demeanor/decisions.json` and honoured by every later run. |
| `informational` | Reported only. Never freezes. Must use `"freeze": "report"`. |

An unresolved `needs-decision` match keeps its original name. The application
is therefore safe; what an unresolved finding costs is obfuscation strength.
CLI and CI builds report unresolved matches rather than failing, unless
`--fail-on-pending-decisions` (MSBuild: `DemeanorFailOnPendingDecisions`) is
set.

Do not give a rule `needs-decision` severity unless a false positive is rare.
A false positive silently freezes working code and costs protection with no
visible symptom.

## The `then` clause

`freeze` takes exactly one of seven action labels. `type-protection` is a
`kind`, not an action — using it as a `freeze` value is rejected.

| Action | Effect |
|---|---|
| `type-and-members` | Freeze each matched type and everything declared on it. The default choice. |
| `by-name-only` | Freeze the matched type names, leaving their members renameable. |
| `members-by-name` | Freeze the matched members by name, leaving their owning type renameable. |
| `properties-of-type` | Freeze every property on each matched type. |
| `properties-by-name` | Freeze exactly the matched property keys. |
| `property-types-of` | Freeze the types of the matched properties, not the properties themselves. |
| `report` | Freeze nothing; surface the match as a finding. The only action an `informational` rule may use. |

### Holding back a protection other than renaming

`freeze` decides which names survive. Code can be incompatible with a
protection that has nothing to do with names — an enum whose members are
formatted into text cannot have those members deleted, whatever its type is
called. Set `kind` to `feature-exclusion` and name the protection in
`then.exclude-feature`. The matched types are obfuscated as usual in every
other respect; only the named protection steps aside.

The features are the ones `[Obfuscation(Feature=...)]` accepts, so a rule
reaches the same outcome someone could have written by hand on the type.
`rename` is not among them — that is what `freeze` decides.

| Feature | Holding it back means |
|---|---|
| `enum-deletion` | Keep an enum’s member names, so the value still formats and parses by name. |
| `string-encryption` | Leave literal strings in the matched types readable. |
| `constant-encryption` | Leave integer constants in the matched types as written. |
| `call-hiding` | Call the matched types’ methods directly instead of through a relay. |
| `cfg` | Leave the matched types’ method bodies in their original control flow. |
| `anti-debug` | Place no debugger-detection checks in the matched types. |
| `anti-tamper` | Exempt the matched types from integrity verification. |
| `baml` | Leave compiled XAML resources referencing the matched types untouched. |

### Saying which configurations a rule is about

A rule warning what holding back a protection costs has nothing to tell someone
who is not applying that protection. An optional `applies-when` clause keeps
it quiet in configurations it has no question for:

```json
"applies-when": { "setting": "enum-deletion", "at-least": "on" }
```

Every setting has ordered levels and the rule names the level it needs, so
*at least* means the same for a two-state feature as for control flow's four —
a rule asking for `flatten` stays quiet on a build that only reorders. Omit
`at-least` and it means the strongest level. Omit the clause and the rule
applies everywhere.

No command-line flag mirrors settings into the audit: a rule states its own
precondition and is evaluated against the settings a build is using.
`demeanor audit` runs before options are chosen and describes the default
build, where every setting is at its strongest.

| Setting | Levels, weakest first |
|---|---|
| `license` | `community, enterprise` |
| `cfg` | `none, reorder, predicates, flatten` |
| `rename` | `off, on` |
| `enum-deletion` | `off, on` |
| `string-encryption` | `off, on` |
| `constant-encryption` | `off, on` |
| `call-hiding` | `off, on` |
| `anti-debug` | `off, on` |
| `anti-tamper` | `off, on` |
| `baml` | `off, on` |
| `aggressive` | `off, on` |

## The `when` clause

Four shapes, composable to any depth:

```json
{ "predicate": "<id>", "args": <value> }
{ "all": [ <clause>, <clause> ] }
{ "any": [ <clause>, <clause> ] }
{ "not": <clause> }
```

`all` over predicates producing different match *kinds* (one emits types,
another emits properties) intersects to empty. Keep every arm of an `all` on
the same kind.

## Predicate vocabulary

An id outside this list is rejected when the catalog loads. The engine never
guesses at an unknown id.

### No arguments

These match a shape the engine can see without being told what to look for. Omit `args` entirely.

| Predicate | Matches |
|---|---|
| `serializable-flag` | Types carrying the `[Serializable]` metadata flag. Compiler-generated types are excluded. |
| `type-shape-poco` | Types with the plain-data shape: a public parameterless constructor plus at least one public get/set auto-property. |
| `anonymous-type` | Types the C# compiler emits for anonymous-object literals. |
| `compiler-generated` | Types carrying `[CompilerGenerated]` — closures, async and iterator state machines, record equality contracts. |
| `efcore-entity-target` | Types appearing as `T` in a `DbSet<T>` property on any `DbContext` subclass. |
| `enum-names-read` | Enum types whose member names the running program can observe — formatted into text, parsed back from it, reflected over, or serialised by name. Paired with `exclude-feature: "enum-deletion"`, since deleting the members of such an enum makes it print numbers instead of names. |
| `ioptions-binding-target` | Types appearing as `T` in `IOptions<T>`, `IOptionsSnapshot<T>` or `IOptionsMonitor<T>` in any method signature. |
| `aspnet-middleware-shape` | Types matching ASP.NET Core's middleware convention — a public `Invoke` or `InvokeAsync` returning a task type. |
| `minimal-api-advisory` | Minimal API endpoint registrations, with the type each handler returns. Advisory use. |
| `mvc-action-payload-target` | The types an MVC or Web API action binds from the request, or returns as its response. Found by walking the action signatures of `Controller`/`ControllerBase` subclasses and `[ApiController]` types; response wrappers such as `Task<ActionResult<T>>` are unwrapped to the type that is actually serialised. |

### A single string argument

Pass the bare name: `"args": "IPlugin"`. Names match on the **simple name**, not the assembly-qualified one — which is why a rule can match a framework type without your project referencing that framework.

| Predicate | Arguments | Matches |
|---|---|---|
| `implements-interface` | interface simple name | Types implementing it, directly or through the base chain. |
| `subclass-of` | base type simple name | Types whose base chain contains it. Generic arity variants match, so `Hub` also matches a generic `Hub`. |
| `assembly-uses-interface` | interface simple name | Assembly-level guard: if any type implements it, every type in the assembly matches. Use inside an `all` to scope a rule to assemblies that use a framework at all. |
| `field-has-attribute` | attribute type name | Types owning at least one field with that attribute. |
| `method-has-attribute` | attribute type name | Types owning at least one method with that attribute. |
| `property-has-attribute` | attribute type name | Types owning at least one property with that attribute. |
| `tooled-by` | generator tool name | Types carrying `[GeneratedCode("toolname", ...)]`. Prefer this over `has-attribute` when the generator's identity is the signal. |

### String or object

Either form is accepted; the object form exposes extra settings.

| Predicate | Arguments | Matches |
|---|---|---|
| `has-attribute` | `"Name"`, or `{ "name": "Name", "inheritable": true }` | Types carrying the attribute. With `inheritable`, the base chain is searched too. |
| `has-method-named` | `"Method"`, or `{ "names": ["A", "B"] }` | Types owning a method with any of those simple names. |

### Object arguments

These read IL or attribute payloads and must be told what to look for. Where a field is shown as `x` / `xs`, supply exactly one of the two.

| Predicate | Arguments | Matches |
|---|---|---|
| `base-type-name-pattern` | `name-suffix`, `namespace-contains` | Types whose direct base matches a naming convention. **Unsafe on an `auto-protect` rule** — a coincidental name freezes real code silently. Prefer `subclass-of`. |
| `attribute-typeof-arg` | `attribute-names` (array), `arg-index` | The type named by a `typeof()` argument inside an attribute, not the type the attribute sits on. This is how `[JsonSerializable(typeof(Dto))]` protects `Dto`. |
| `attribute-arg-references-property` | `attribute-name`, `arg-index` | Property keys where an attribute's string argument names a property on the same type. |
| `attribute-named-arg-equals` | `attribute-name`, `arg-name`, `arg-value` | Types whose attribute carries a named argument set to a given value. |
| `il-calls` | `method` / `methods`, `declaring-type` / `declaring-type-suffix` / `declaring-namespace`, `capture` | Types captured from call sites. `capture` is `generic-arg-0` (the `T` of a closed generic call — it fires only on closed generics) or `caller-owner` (the type containing the calling method). |
| `il-typeof-flows-to-ctor` | `ctor-of` / `ctor-of-types`, `capture` | Types `T` from `new Target(typeof(T))`. `capture` is `nearest-typeof` (default) or `all-typeof-args` when the constructor takes several. |
| `il-typeof-flows-to-method` | `method` / `methods`, `declaring-type`, `arg-index` | Types `T` passed as `typeof(T)` to a call, captured at the given argument position. |
| `il-typeof-flows-to-property` | `property` / `properties` | Types `T` from `typeof(T).Name`, `typeof(T).FullName` and similar reads. |
| `il-ldstr-flows-to` | `callee` / `callees`, `declaring-type`, `capture` | String literals flowing into a call. `capture` is `type-name` or `member-key-or-name`. |
| `il-call-string-arg-at` | `method` / `methods`, `declaring-type`, `arg-index`, `capture` | A string literal at a specific argument position. `capture` is `string-name`, `method-name` or `property-name`. |
| `field-or-prop-set-by` | `callee` / `callees`, `declaring-type` | Property keys registered by name in a constructor — the WPF `DependencyProperty.Register` shape. |
| `aot-json-advisory` | `pattern` | Native-AOT-unsafe `System.Text.Json` call sites. Advisory use. |

Matching is by simple name, and sometimes namespace, never by assembly
identity. That is what lets one rule cover a framework type across every
version and package that declares it, and it is also why a rule can fire on a
local type that merely shares a name.

Name the attribute **type**, as metadata records it: `RpcAttribute`, not the `[Rpc]` spelling C# source shows. This is the same rule every predicate follows — `implements-interface` takes `IPlugin`, `subclass-of` takes `ApiControllerBase` — and one spelling per thing keeps two developers from writing the same rule two ways. A name that is not in the assembly matches nothing; a dry run shows that before the rule is ever written, and the audit lists project rules that matched nothing.

## Minimal valid rule

```json
{
  "id": "myteam-plugin-contract",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Plugin contracts must keep their public surface",
  "reasoning": "Plugins are loaded by reflection from third-party DLLs outside this repo. Renaming the interface members breaks downstream integrators that bind by name.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": { "predicate": "implements-interface", "args": "IPlugin" },
  "then": { "freeze": "type-and-members", "report-as": "myteam-plugin-contract" }
}
```

## Validating

Call `demeanor_dry_run_rule` with the candidate JSON before writing any file.
It runs full schema validation and reports which types and members the rule
would match, without persisting anything. `demeanor_propose_rule` adds a
validated rule to the session layer so it affects the current run;
`demeanor_promote_rule` writes it to `.demeanor/patterns/` to make it
permanent.

A rule rejected at load time is reported by file name, rule id and reason. It
is never skipped silently — a rule believed to be protecting something, that
is not, is worse than no rule.

## Common rejections

- `"freeze": "type-protection"` — that is a `kind`. Use `type-and-members`.
- `"severity": "advisory"` — not a severity. Use `informational`.
- `informational` rule declaring any `freeze` other than `report`.
- `needs-decision` or `informational` rule with no `remediation`.
- `report-as` not equal to the rule's `id`.
- `reasoning` under 40 characters, or written in IL/SDK terms.
- An `id` that already exists in the merged catalog, when proposing a rule.
