Authoring a rule by hand

The working reference for writing a project rule directly — the JSON shape, the predicate vocabulary, composition with all / any / not, and a set of worked examples covering the patterns a hand author actually reaches for.

If you’re using Claude Code or another MCP-capable assistant, the conversational workflow is the faster path — the assistant proposes a rule with all of these fields filled in and lets you approve before it lands in .demeanor/patterns/. This page exists for everyone else — the developer who wants to write a rule directly in JSON against a known reflection-by-name pattern in their code, with a text editor and a dry-run, no AI in the loop.

Who this page is for

You already know the pattern in your code that’s load-bearing for runtime behaviour — the homegrown plugin interface, the framework-shaped class you ship for an integration partner, the convention your team enforces. You want to encode it as a rule so the audit treats it as resolved without prompting on every run. Hand-authoring is the right path when:

  • You already understand the runtime mechanism (a reflection lookup, a serializer’s name binding, a framework’s by-name dispatch) and don’t need a conversation to identify it.
  • You want the rule under version control before the next obfuscation run.
  • Your CI doesn’t allow AI assistants in the loop and you need the rule authored before a build.

If you’re unsure whether a pattern needs a rule at all, the conversational workflow at the walkthrough page is a faster diagnostic — it surfaces ambiguous patterns and writes the rule for you. Come back here once you know what you want to author.

The minimum rule

The smallest rule that does anything useful. Every field is required unless flagged otherwise.

{
  "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": "MyCompany.Plugins.IPlugin" },
  "then": { "freeze": "type-protection", "report-as": "myteam-plugin-contract" }
}

This rule freezes every type in the assembly that implements MyCompany.Plugins.IPlugin. The audit reports the matches under auto-protected and the obfuscator excludes those types from renaming.

Drop the file at <repo>/.demeanor/patterns/plugin-contract.json. The filename doesn’t matter — the engine reads every .json in the folder. One rule per file or several rules per file is fine; you can also place a top-level JSON array of rule objects in a single file. The audit reloads files on every run; there’s no daemon and no cache to bust.

The full schema

Every field on a rule, in alphabetical order within required vs. optional.

Required fields

FieldTypeValue
idstringKebab-case identifier matching ^[a-z0-9]+(-[a-z0-9]+)+$. Use a team or namespace prefix (myteam-foo) so the id can’t collide with a built-in rule. Same id in a higher layer shadows the lower layer.
versioninteger1 for all rules today. Bump when the rule’s matching semantics change in a way callers should re-review.
predicate-vocabinteger1 for all rules using the current predicate set. The engine refuses to load a rule that asks for a predicate vocabulary it doesn’t support.
kindstringtype-protection for the common case (protect every type that matches). The other hand-author choices are aot-json-advisory and minimal-api-advisory — both advisory-only kinds that emit a finding without freezing anything. (Other kind values exist for internal Demeanor analyzers and don’t have a useful landing spot in user-authored rules.)
summarystringOne-line description shown in the audit report. Plain English; this is what your team sees in PR review.
reasoningstringExplanation in C# vocabulary, at least 40 characters. Tell the reader what runtime mechanism reads the name and what breaks if it’s renamed. Don’t use IL opcode mnemonics or internal SDK class names — those go in evidence_il below.
severitystringauto-protect (protect silently), needs-decision (pause and ask), or informational (emit an advisory finding, never freeze).
provenancestringRequired by the schema, but the loader pins it to the file’s actual layer (project for files under .demeanor/patterns/, user for files under ~/.demeanor/patterns/) regardless of what you write. The conventional value for project-layer files is project; treat the field as documentation and move on.
licensestringany unless the rule should only apply at Enterprise tier, in which case enterprise.
whenobjectThe match clause. See the next section.
thenobjectThe action. Today: { "freeze": "<action>", "report-as": "<rule-id>" }. The report-as must equal the rule’s own id. See section 5.

Optional fields

FieldTypeValue
evidence_ilstringIL-level detail — opcode mnemonics, signature shapes, or other technical text that doesn’t belong in reasoning. Shown in the audit report’s verbose mode for engineers, hidden from the default human-readable output.

The when clause — predicates and composition

The when clause is what makes a rule expressive. It’s either a single predicate invocation or a composition built from intersections, unions, and complements over sub-clauses.

The four shapes

ShapeSyntaxMeaning
Primitive predicate{ "predicate": "<id>", "args": <value> }Invoke a single predicate from the catalog. The args shape depends on the predicate.
Intersection{ "all": [ <clause>, <clause>, ... ] }Matches only what every sub-clause matches.
Union{ "any": [ <clause>, <clause>, ... ] }Matches anything any sub-clause matches.
Complement{ "not": <clause> }Matches types the sub-clause does not match. Carve-out semantics.

Composition nests freely. An all can contain other all, any, not, or primitive clauses; the same for any. A not takes exactly one sub-clause.

Predicate catalog — the ones a hand author will reach for

The predicates below cover the patterns most hand-authored rules need. The catalog grows; if you need a predicate that isn’t listed here, the conversational workflow surfaces what’s available against your specific code.

Type-shape predicates

Predicate IDArgsMatches
implements-interfacebare string (interface simple name)Types implementing the named interface, directly or via the base chain. Example: "args": "IPlugin".
subclass-ofbare string (base type simple name)Types whose base chain contains the named type, with generic-arity tolerance (Hub matches Hub<T>). Example: "args": "ApiControllerBase".
serializable-flagnoneTypes whose [Serializable] runtime flag is set.
has-method-namedbare string OR { "name": "...", "static": <bool>, "any-args": <bool> }Types declaring at least one method with the given simple name. The object form filters by static/instance and presence of parameters.
assembly-uses-interfacebare string (interface name)A gate predicate — returns every type when the assembly has an InterfaceImpl naming the interface, otherwise empty. Use it inside an all clause to scope another predicate to “only fires when this assembly is a WPF / WCF / etc. assembly.”

Attribute predicates

Predicate IDArgsMatches
has-attributebare string OR { "name": "...", "inheritable": <bool> }Types carrying a custom attribute with the given simple name. The inheritable: true form walks the base chain (matches Attribute.GetCustomAttribute(inherit: true)).
property-has-attributebare string OR { "name": "..." }Properties carrying the named attribute. The rule freezes the property and its owning type.
field-has-attributebare string OR { "name": "..." }Fields carrying the named attribute. The rule freezes the field and its owning type.
tooled-bybare string (tool name)Types carrying [GeneratedCode("<tool>", ...)] matching the argument.

IL-flow predicates

These scan method bodies for value-flow patterns and freeze whichever type or member is on the receiving end of the flow. The object-shaped args selects what gets captured.

Predicate IDArgsMatches
il-typeof-flows-to-ctor{ "ctor-of": "<TypeName>" } or { "ctor-of-types": [...] }The T in new Foo(typeof(T), ...) patterns — flows the typeof’d type into the freeze set.
il-ldstr-flows-toobjectThe compile-time string literal in Foo.Bar("SomeName") patterns — typically reaches into the freeze set when a runtime API resolves things by string name. Filters by the callee that consumes the literal.
attribute-arg-references-propertyobjectThe property named in a custom-attribute argument (e.g. [Display(Name = nameof(Foo))]-style references).
field-or-prop-set-byobjectThe field or property assigned from a call to a named API — useful when a framework registers names through a setter.

The il-calls predicate — full schema

il-calls is the most expressive of the IL-flow predicates and the one you’ll reach for when a framework discovers methods or types by scanning IL for calls to a registry API. Because it’s richer than the bare-string predicates, its args is always an object.

FieldRequiredValue
methodone of method / methods requiredSimple method name of the call target. Matches call and callvirt instructions whose operand resolves to this name.
methodsone of method / methods requiredArray of simple method names. Any match. Use when several APIs on the same registry are interesting (e.g. ["Register", "RegisterSingleton", "RegisterScoped"]).
declaring-typeoptionalSimple name of the callee’s owning type. Accepts only calls whose owner’s simple name matches.
declaring-type-suffixoptionalSuffix match on the callee’s owning type. Unions with declaring-type — either match accepts. Useful for framework conventions like "Builder" across vendor-specific types.
declaring-namespaceoptionalNamespace filter. Disambiguates same-name types in different namespaces — System.Text.Json.JsonSerializer versus Newtonsoft.Json.JsonSerializer being the canonical case.
capturerequiredWhat the rule freezes when it finds a matching call. One of generic-arg-0 or caller-owner — see below.
Capture modes
generic-arg-0
Freezes the first generic instantiation argument of the call. The IL operand must be a generic MethodSpec — non-generic calls don’t fire under this capture. Use this for APIs like services.Register<T>() where T is the registered type.
caller-owner
Freezes the type that contains the call. Use this when the calling type itself is what the framework discovers — for example, a startup class that wires up handlers via a fluent builder; if the builder calls fire from a startup type, you freeze the startup type so reflection can still find it by name.
Example — generic-arg-0 capture

Source pattern: an internal DI container exposes services.RegisterHandler<THandler>(); the framework later instantiates each registered type by name.

{
  "id": "myteam-handler-registrations",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Handler types registered with the DI container",
  "reasoning": "The container records each handler's type name at startup and resolves it by name when a request arrives. Renaming a registered handler type means the request loop can't locate it and the request 500s with a missing-handler diagnostic.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": {
    "predicate": "il-calls",
    "args": {
      "method":         "RegisterHandler",
      "declaring-type": "ServiceCollectionExtensions",
      "capture":        "generic-arg-0"
    }
  },
  "then": { "freeze": "type-protection", "report-as": "myteam-handler-registrations" }
}
Example — caller-owner capture

Source pattern: a feature-flag framework expects every “configurator” class to call features.Configure(...) in its constructor. The framework scans assemblies for types that contain a call to Configure and wires them up by name.

{
  "id": "myteam-feature-configurators",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Feature-flag configurator classes",
  "reasoning": "The feature-flag framework discovers configurators by scanning for types whose code calls features.Configure, then activates each one by class name at startup. Renaming a configurator's type strips it from the discovered set and the feature never gets configured.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": {
    "predicate": "il-calls",
    "args": {
      "method":         "Configure",
      "declaring-type": "FeatureRegistry",
      "capture":        "caller-owner"
    }
  },
  "then": { "freeze": "type-protection", "report-as": "myteam-feature-configurators" }
}

If the call you’re tracking takes a string-literal name as a regular argument (not a generic parameter), il-ldstr-flows-to is usually the better fit — same scanning principle, but it captures the string literal’s value rather than a type at the call site.

The catalog also exposes framework-specific predicates that the built-in rules use internally — analyses tailored to particular framework conventions where the right rule depends on framework-specific knowledge. You rarely need to author rules around these directly; the built-in coverage already fires on them. If your project has a framework-convention pattern that the built-in rules don’t catch, the conversational workflow is the path that surfaces them and proposes a rule shape against your code. (The one exception is assembly-uses-interface, which is genuinely useful for hand-authoring — it’s in the type-shape table above.)

Composition examples

Intersection (all)

“Every type that implements IMessage and carries the [MessagePackObject] attribute.”

"when": {
  "all": [
    { "predicate": "implements-interface", "args": "IMessage" },
    { "predicate": "has-attribute",        "args": "MessagePackObject" }
  ]
}

all filters more strictly than either predicate alone — types implementing IMessage without the attribute don’t match, and vice versa.

Union (any)

“Every type that inherits our ApiControllerBase or carries our project-internal [ApiSurface] attribute.”

"when": {
  "any": [
    { "predicate": "subclass-of",   "args": "ApiControllerBase" },
    { "predicate": "has-attribute", "args": "ApiSurface" }
  ]
}

any matches anything any sub-clause matches — useful when the same surface is reached via two conventions in the same project.

Complement (not)

“Every IPlugin implementer that is not in our internal test fixture base type.”

"when": {
  "all": [
    { "predicate": "implements-interface", "args": "IPlugin" },
    { "not": { "predicate": "subclass-of", "args": "PluginTestFixtureBase" } }
  ]
}

not is the carve-out: keep the broad match, but exclude one well-defined sub-shape.

The then clause — actions

Today, every project rule sets:

"then": { "freeze": "type-protection", "report-as": "<id>" }

The freeze value names the action. type-protection is the standard “protect every type the when matched” behaviour and covers the overwhelming majority of project rules. The report-as field must equal the rule’s own id — the audit uses it to attribute the finding back to the rule that fired.

Other action vocabulary exists for the specialised kind values (advisories, analyzers) but is rarely hand-authored. Stick with the type-protection shape unless your kind isn’t type-protection.

File placement and pickup

  • Project rules<repo>/.demeanor/patterns/<anything>.json. Commit to git. The team and CI see them on the next audit run.
  • User rules~/.demeanor/patterns/<anything>.json. Personal to you across every project on this machine. Not committed.
  • Multiple rules per file — the file may contain a single rule object, or a top-level JSON array of rule objects. Both shapes work. Pick whichever your team prefers in PR review.
  • Pickup is on every run — no daemon, no cache to bust, no reload command. New and changed files are picked up the next time demeanor audit runs.

Validating a rule

Three loops, in order of speed:

1. Dry-run the audit

demeanor audit MyApp.dll --include-deps

The audit reads the rule store and prints what every rule matched. If your when clause is malformed, the audit fails fast with the rule’s id and a schema error pointing at the failing field.

2. Inspect the JSON report

demeanor audit MyApp.dll --include-deps --json > audit.json

The structured report enumerates every rule that matched. Look for your rule’s id under auto-protected, needs-decision, or advisories. If it isn’t in any of those, it didn’t match anything — re-check the when clause.

3. Run obfuscation against a dry-run report

demeanor MyApp.dll --include-deps --dry-run --report dry.json

The full pipeline runs without writing output files; dry.json records every rename and exclusion decision. Cross-check the types you expected your rule to protect against the excluded section of the report. The report schema is documented at Reports & Incremental.

Worked examples

Six common shapes a hand author writes. For each: the source pattern, then the rule that protects it, then one sentence on what would break without the rule. All identifiers are third-party invented — substitute your own.

1. A plugin contract

Source pattern: an internal interface that third-party DLLs implement, loaded via reflection at runtime.

namespace MyCompany.Plugins;

public interface IPlugin
{
    string Name { get; }
    void Initialize(IServiceProvider services);
}
{
  "id": "myteam-plugin-contract",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Plugins are loaded by reflection from external DLLs",
  "reasoning": "Plugin DLLs ship from third parties and discover plugin types by interface implementation at runtime. Renaming IPlugin or its members breaks every integrator that binds by name against the interface contract.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": { "predicate": "implements-interface", "args": "MyCompany.Plugins.IPlugin" },
  "then": { "freeze": "type-protection", "report-as": "myteam-plugin-contract" }
}

Without this rule: the obfuscator renames plugin types to short identifiers; third-party DLLs implementing IPlugin fail to load because the interface members they bound against no longer exist by name.

2. A custom serializer attribute

Source pattern: a homegrown [Persisted] attribute that the project’s storage layer reflects on to decide which properties to serialize.

[AttributeUsage(AttributeTargets.Class)]
public sealed class PersistedAttribute : Attribute { }

[Persisted]
public sealed class UserPreferences
{
    public string Theme { get; set; } = "dark";
    public int RetainDays { get; set; } = 30;
}
{
  "id": "myteam-persisted-types",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Types marked [Persisted] are serialized by name",
  "reasoning": "The storage layer enumerates [Persisted] types at startup and reads their properties by name to produce a wire format that older versions can read back. Renaming the type or its properties silently corrupts the persisted store on disk.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": { "predicate": "has-attribute", "args": "Persisted" },
  "then": { "freeze": "type-protection", "report-as": "myteam-persisted-types" }
}

Without this rule: the type and its properties rename to short identifiers; on the next startup the storage layer can’t locate UserPreferences.Theme and falls back to defaults, silently losing user state.

3. An MVC controller convention

Source pattern: every API controller in the project inherits from a project-specific base type that the framework discovers via reflection.

public abstract class ApiControllerBase : ControllerBase { ... }

public sealed class OrdersController : ApiControllerBase { ... }
public sealed class CustomersController : ApiControllerBase { ... }
{
  "id": "myteam-api-controllers",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "API controllers route by class name",
  "reasoning": "Every type inheriting ApiControllerBase is discovered at startup by MVC's controller feature provider and registered under its class name as a routing target. Renaming the class changes the route segment and breaks every existing client URL.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": { "predicate": "subclass-of", "args": "ApiControllerBase" },
  "then": { "freeze": "type-protection", "report-as": "myteam-api-controllers" }
}

Without this rule: controllers rename to short identifiers; routes that previously read /orders/... become /a/... and every deployed client breaks.

4. An RPC method registry — intersection

Source pattern: an internal RPC framework discovers methods marked [Rpc] on types that also implement an IRpcSurface marker. The combination is meaningful; either alone shouldn’t fire the rule.

public interface IRpcSurface { }

public sealed class BillingService : IRpcSurface
{
    [Rpc] public Task ChargeAsync(string customerId, decimal amount) => ...;
    public void InternalHelper() { ... }   // not exposed
}
{
  "id": "myteam-rpc-surface",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "RPC surface types must keep [Rpc] method names",
  "reasoning": "The RPC framework enumerates [Rpc]-marked methods on IRpcSurface types at startup and registers them by name as remote endpoints. Both conditions must hold for a type to be in the surface; renaming any [Rpc] method breaks every caller that already bound to its name.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": {
    "all": [
      { "predicate": "implements-interface", "args": "IRpcSurface" },
      { "predicate": "has-method-named",     "args": { "name": "ChargeAsync", "any-args": true } }
    ]
  },
  "then": { "freeze": "type-protection", "report-as": "myteam-rpc-surface" }
}

Without this rule: the type and methods rename; clients calling BillingService.ChargeAsync hit a 404-equivalent on the RPC layer. (In practice you’d author one rule per method name or use a method-shape predicate that captures the whole [Rpc] set; this example shows the composition shape.)

5. A rule that only fires in WPF assemblies

Source pattern: a convention that only matters when the assembly being obfuscated is a WPF assembly. Use assembly-uses-interface as a gate so the rule is inert in non-WPF assemblies.

{
  "id": "myteam-wpf-codebehind",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Code-behind types in WPF assemblies route by class name from XAML",
  "reasoning": "WPF's XAML loader resolves Window and UserControl code-behind types by their compile-time class name via x:Class. The lookup only matters in assemblies that actually use the WPF dispatcher. Outside a WPF assembly, a class named Window has no special meaning.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": {
    "all": [
      { "predicate": "assembly-uses-interface", "args": "System.Windows.Threading.IDispatcherFrame" },
      {
        "any": [
          { "predicate": "subclass-of", "args": "Window" },
          { "predicate": "subclass-of", "args": "UserControl" }
        ]
      }
    ]
  },
  "then": { "freeze": "type-protection", "report-as": "myteam-wpf-codebehind" }
}

Without this gate: a non-WPF assembly that happens to contain a type called Window (an unrelated business term) would be protected unnecessarily. The gate keeps the rule scoped to its intended context.

6. A typeof-driven registration

Source pattern: a registry constructor that takes a Type argument; callers pass typeof(SomeType) at compile time. The registered Type needs its name preserved.

public sealed class TypeRegistry
{
    public TypeRegistry(Type target, string canonicalName) { ... }
}

// Caller, somewhere else in the codebase:
new TypeRegistry(typeof(Invoice), "invoice");
new TypeRegistry(typeof(CreditNote), "credit-note");
{
  "id": "myteam-registered-types",
  "version": 1,
  "predicate-vocab": 1,
  "kind": "type-protection",
  "summary": "Types passed to TypeRegistry must keep their names",
  "reasoning": "TypeRegistry serialises type identity by full name. Renaming a type whose typeof flows into a TypeRegistry constructor changes its serialised name and invalidates any persisted record that references the type.",
  "severity": "auto-protect",
  "provenance": "project",
  "license": "any",
  "when": { "predicate": "il-typeof-flows-to-ctor", "args": { "ctor-of": "TypeRegistry" } },
  "then": { "freeze": "type-protection", "report-as": "myteam-registered-types" }
}

Without this rule: Invoice renames to a; the registry stores "a" as the canonical name; the persisted record on disk that says "Invoice" stops matching.

Common authoring mistakes

“My rule loads but matches nothing.”

Run demeanor audit --json and check whether the rule appears at all. If it’s not in the JSON, the id is malformed or duplicates another rule’s id and got dropped. If it’s there with zero matches, the when clause’s predicate args don’t match any types in your assembly — double-check the simple name (case-sensitive) and any namespace qualifier you supplied.

“The audit reports a schema error and names my rule.”

The error message names the failing predicate and field. Common causes: passing a bare string to a predicate that requires an object, or vice versa (e.g. il-calls requires an object; implements-interface accepts either). Re-read the predicate’s args column in section 4.

“My reasoning field is rejected at load time.”

The reasoning lint forbids IL opcode mnemonics and internal SDK class names in user-facing reasoning — that text shows up in your team’s PR diffs and audit reports, and the engine wants it in C# vocabulary. Move IL-level detail to the optional evidence_il field; keep reasoning in plain-English C#.

“My all clause matches less than either predicate alone.”

That’s intersection — only what every sub-clause matches gets through. If you wanted “either of these,” switch to any.

“My not clause matches nothing.”

A not over a clause that emits member-level matches (properties, fields) returns empty for that kind today — complement populates type-level matches only. Restructure so the not applies at the type level, or use a positive predicate that already excludes what you want to carve out.

Next steps

  • Rules — the conceptual overview and the four-layer hierarchy
  • Exclusions Guide[Obfuscation] attributes for the cases where a project rule would be over-scoped
  • Conversational walkthrough — how the AI-assisted workflow proposes rules of this exact shape
  • Decisions & CI — what CI sees when a rule is needs-decision vs. auto-protect
  • Reports & Incremental — the dry-run report schema, used for validating new rules