Open Vocabularies Are String Constants

The rule in one sentence: a set of named values that is persisted, serialised, or extended by a module is a static class of const string, named exactly as the enum would have been — never a C# enum — and it stays open for anyone else to extend with their own constants. Policy open-vocabulary-string-constants (Policy Not Prose).

// ✅ the shape
public static class TransportKind
{
    public const string InApp    = "InApp";
    public const string Email    = "Email";
    public const string Teams    = "Teams";
    public const string WhatsApp = "WhatsApp";
}

public record Participant
{
    /// <summary>A <see cref="TransportKind"/> constant.</summary>
    public string Transport { get; init; } = TransportKind.InApp;
}

The value is the member name, spelled identically. TransportKind.Email reads at a call site exactly as the enum member did, which is what makes converting an existing enum a drop-in.

🚨 The vocabulary stays OPEN — that is the whole point

The platform's constants class is a starting set, never the permitted set. Any other party — a module, a plugin, a satellite repo, a customer deployment, another agent — declares its own static class of const string and uses those values in the same field. Nothing registers them, nothing validates against an allow-list, and the platform neither knows nor needs to know.

// In someone else's module. No coordination with the platform, no PR to core.
public static class AcmeTransportKind
{
    public const string Signal   = "Signal";
    public const string PagerDuty = "PagerDuty";
}

participant with { Transport = AcmeTransportKind.PagerDuty }

That value now flows through the platform's records, storage, queries and UI untouched. The platform's own code does not recognise it, routes it to the handler that does, and says so where it cannot.

Two obligations follow, and they are the price of the openness:

A vocabulary that a third party cannot extend without a change to core is a vocabulary that should have been an enum. If this one could be closed, it would not need this shape.

🚨 Resolution is a CHAIN OF RULES, never a switch

An open vocabulary and a centralised switch are contradictory: the moment anyone can add a value, no single site can know them all. So the values are open and the dispatch is a chain — the two halves of one design, and shipping the first without the second gives you an extensible vocabulary nothing can act on.

Each party registers a rule for the values it owns. Resolution walks the chain in order and the first rule that claims the value handles it.

🚨 The chain is DURABLE — the rules are mesh nodes, not DI registrations

A rule is a node. It is written, read, versioned, queried and edited like everything else; it survives a restart, and adding one is a node write rather than a redeploy. A chain assembled from compiled registrations would be invisible (you cannot ask a running system what its chain is), unversioned, and unchangeable without shipping a build — and holding it in a static list is forbidden outright (No Static State).

public record DispatchRule
{
    [Key] public string Id { get; init; } = Guid.NewGuid().ToString();

    /// <summary>The values this rule claims — ITS OWN constants, never the platform's set.</summary>
    public ImmutableArray<string> Claims { get; init; } = [];

    /// <summary>Lower runs first. Explicit and DURABLE — never registration or load order.</summary>
    public int Order { get; init; }

    /// <summary>The handler that acts: a node path, resolved when the rule fires.</summary>
    [MeshNode] public string Handler { get; init; } = string.Empty;

    public bool Enabled { get; init; } = true;
}

Three things follow from the chain being nodes:

This is the shipped precedent, not a new invention: NotificationRule already lives at {user}/_NotificationRule/{id} as a durable, user-authored node resolved by explicit order precedence.

The handler can be a Code node — compiled by the backend, cached

Handler is a node path, and that node may be in-mesh C#: a Code node the backend compiles and executes, with the compiled assembly cached — keyed by path and version, so it is compiled once and reused across dispatches, not per message.

That is what takes the openness to its conclusion: a new transport can be added with no deployment at all. Its constants, its rule node and its handler source are all written into the mesh, and the chain picks it up live. Nothing is rebuilt, nothing is redeployed, and core never learns the value exists.

🚨 Four consequences, all of which bite in production:

In-mesh handler source is held to the same warning standard as the rest of the mesh's C# — see In-Mesh Warning Standard.

What makes the chain correct

Four properties, and each is a way it goes wrong:

This is the shape the codebase already uses where user intent must beat built-in behaviour: NotificationRule resolves by explicit order precedence, and the Settings page composes itself from contributed tabs rather than a hard-coded list (Settings Page).

🚨 A chain resolves BEHAVIOUR, not validity. It decides who acts on a value; it never decides whether the value is allowed. Reintroducing membership validation as "no rule claims it, so reject it" is the closed vocabulary again — the unclaimed value must still store, query and render.

Why

1. A new member must not break code the compiler cannot see

Widening a public enum makes every exhaustive switch over it non-exhaustive. Under -warnaserror that is a build failure — and not only in src/. Every .cs stored in a mesh node compiles at RUNTIME in the portal, never in CI (NodeType Compilation), so a widened enum can leave a NodeType that no dotnet build ever type-checked failing to compile on a pod, discovered only when somebody opens the page. Adding a const string breaks nobody, anywhere.

For a vocabulary that exists in order to be extended — transports, participant kinds, channel types, anything a module contributes to — that alone settles it.

2. The zero member is a silent wrong answer

An enum deserialising a value it does not know either throws or, far worse, yields the zero member — and the zero member is almost always a real, meaningful value (InApp, Person, Running). A message from a newer peer, a hand-authored node JSON, or a module this build has never heard of then reads as a plausible wrong value with nothing logged. A string round-trips: unknown stays unknown, visible, and reportable.

This matters more here than in most codebases, because node content is JSON that outlives the assembly that wrote it and is frequently authored by hand.

3. It is already a string on the wire

These values serialise as strings either way. Modelling them as strings removes a conversion that can fail rather than adding one.

4. Migration is a drop-in

Because the static class keeps the enum's name and its members keep their spelling, an existing enum converts in place: change the declaration and the field's type: every TransportKind.Email at every call site is untouched. That is what makes converting a shipped enum a mechanical change rather than a sweep.

How consumers read one

Compare; never exhaust.

// ✅
if (participant.Transport == TransportKind.Email)
    …
else
    logger.LogWarning("Unknown transport {Transport} on {Path} — skipped", participant.Transport, path);
// ❌ a switch with no default over an open vocabulary is the bug this shape prevents
switch (participant.Transport) { case TransportKind.Email: …; case TransportKind.Teams: …; }

🚨 Always carry the unknown branch, and make it say so. The whole gain is that an unrecognised value arrives intact instead of being coerced; throwing it away silently spends that gain.

Ordering, where a vocabulary needs one, is an explicit order field or a lookup table — never the declaration order of the members, which a string vocabulary does not have.

When an enum is still right

An enum remains correct for a vocabulary that is all three of:

A local state machine private to one class is the typical case. LogLevel-style severity, a parser's token kind, a comparison operator — fine. The moment a value is written to a node, crosses the wire, or is contributed by a module, it is a string.

What this does NOT mean

Cross-references