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": "IPlugin" },
"then": { "freeze": "type-and-members", "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.
The argument is IPlugin, not the namespace-qualified name. Predicates compare the simple name, so a qualified argument matches nothing — and matching nothing is not an error, which is why it is worth getting right the first time.
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
| Field | Type | Value |
|---|---|---|
id | string | Kebab-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. |
version | integer | 1 for all rules today. Bump when the rule’s matching semantics change in a way callers should re-review. |
predicate-vocab | integer | 1 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. |
kind | string | type-protection for the common case (protect every type that matches). feature-exclusion holds back one protection other than renaming — see section 5. The advisory-only choices are aot-json-advisory and minimal-api-advisory, which 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.) |
summary | string | One-line description shown in the audit report. Plain English; this is what your team sees in PR review. |
reasoning | string | Explanation 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. |
severity | string | auto-protect (protect silently), needs-decision (protect the match and ask), or informational (emit an advisory finding, never freeze). |
provenance | string | Required by the schema, but the loader pins it to the file’s actual layer regardless of what you write. For a file under .demeanor/patterns/ that is always project; write that and move on. |
license | string | any unless the rule should only apply at Enterprise tier, in which case enterprise. |
when | object | The match clause. See the next section. |
then | object | The action: { "freeze": "<action>", "report-as": "<rule-id>" }, or { "exclude-feature": "<feature>", "report-as": "<rule-id>" } on a feature-exclusion rule. The report-as must equal the rule’s own id. See section 5. |
Optional fields
| Field | Type | Value |
|---|---|---|
evidence_il | string | IL-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
| Shape | Syntax | Meaning |
|---|---|---|
| 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.
The predicate vocabulary
Every predicate id Demeanor recognises is listed below. An id outside this list is rejected when the rule loads — the engine never guesses — so check a name here before writing it. Dry-running the rule is the fastest way to confirm both the id and the argument shape.
Driving an assistant? The same reference is served as plain Markdown at /docs/rule-spec.md.
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. |
The il-calls predicate — full schema
il-calls is the most expressive of the il-* 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.
| Field | Required | Value |
|---|---|---|
method | one of method / methods required | Simple method name of the call target. Matches call and callvirt instructions whose operand resolves to this name. |
methods | one of method / methods required | Array of simple method names. Any match. Use when several APIs on the same registry are interesting (e.g. ["Register", "RegisterSingleton", "RegisterScoped"]). |
declaring-type | optional | Simple name of the callee’s owning type. Accepts only calls whose owner’s simple name matches. |
declaring-type-suffix | optional | Suffix 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-namespace | optional | Namespace filter. Disambiguates same-name types in different namespaces — System.Text.Json.JsonSerializer versus Newtonsoft.Json.JsonSerializer being the canonical case. |
capture | required | What 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 likeservices.Register<T>()whereTis 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-and-members", "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-and-members", "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": "MessagePackObjectAttribute" }
]
}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": "ApiSurfaceAttribute" }
]
}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-and-members", "report-as": "<id>" }The freeze value names the action. type-and-members is the standard “protect every type the when matched, and its members” 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.
Do not put type-protection here. That is a kind, not an action; the validator rejects it. There are exactly seven action labels:
| Action | What it does |
|---|---|
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. |
An informational rule that names any other action is rejected at load time. Reporting and freezing are separate channels, and declaring a freeze on a rule that only reports states something the engine will not do.
Holding back a protection other than renaming
A freeze action decides which names survive. Some code is incompatible with a protection that has nothing to do with names — an enum whose members you format into text cannot have those members deleted, whatever its type is called. A rule expresses that by setting kind to feature-exclusion and naming the protection to hold back:
{
"id": "myteam-wire-enums",
"kind": "feature-exclusion",
"severity": "auto-protect",
"when": { "predicate": "enum-names-read" },
"then": { "exclude-feature": "enum-deletion", "report-as": "myteam-wire-enums" }
}The matched types are obfuscated as usual in every other respect — renamed, their strings encrypted, their bodies flattened. Only the one named protection steps aside. The features a rule may name are the same ones [Obfuscation(Feature=…)] accepts, so a rule reaches exactly the outcome someone could have written by hand on the type; the point of writing it as a rule is that nobody has to know to.
| Feature | What 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. |
rename is not among them: renaming is what freeze decides, and one question with two answers in the same file is a rule that contradicts itself. severity works as it does everywhere else — auto-protect holds the protection back silently, needs-decision holds it back and raises a decision for someone to resolve, and informational reports the match without changing what is applied.
Saying which configurations a rule is about
A rule that warns what holding back a protection costs has nothing to tell someone who is not applying that protection. Add an optional applies-when clause and it stays quiet in configurations it has no question for:
"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 thing 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 — for a feature, “only when this is on”. Omit the whole clause and the rule applies everywhere.
This is also why no command-line flag mirrors settings into the audit. A rule states its own precondition, so it is evaluated against whatever settings a build is using. demeanor audit runs before anyone has chosen options 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 |
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. - 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 auditruns.
Validating a rule
Three loops, in order of speed:
1. Dry-run the audit
demeanor audit MyApp.dll --include-depsThe 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.jsonThe 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.jsonThe 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": "IPlugin" },
"then": { "freeze": "type-and-members", "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": "PersistedAttribute" },
"then": { "freeze": "type-and-members", "report-as": "myteam-persisted-types" }
}Note the two spellings. The source writes [Persisted], because C# lets you drop the suffix; the rule writes PersistedAttribute, because that is the class’s name and the rule names the type. Get it wrong and the rule loads clean and matches nothing — dry-run it, and the audit will list it among the project rules that matched nothing.
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-and-members", "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": "method-has-attribute", "args": "RpcAttribute" }
]
},
"then": { "freeze": "type-and-members", "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. Neither arm is sufficient alone: implements-interface would also freeze a marker-only type with nothing exposed, and method-has-attribute would fire on any type that happens to reuse the [Rpc] name outside the surface.
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": "IDispatcherFrame" },
{
"any": [
{ "predicate": "subclass-of", "args": "Window" },
{ "predicate": "subclass-of", "args": "UserControl" }
]
}
]
},
"then": { "freeze": "type-and-members", "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-and-members", "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-decisionvs.auto-protect - Reports & Incremental — the dry-run report schema, used for validating new rules