crates/cordial-runtime/src/flags.rs. A plugin can read the resolved set with provenance, and write one layer: its own.
The layers, and who wins
flags::collect() builds the layers lowest first and flags::resolve() applies them in that order, so the last layer to name a key wins.
The user always wins. No call, parameter or ordering trick lets a plugin beat layer 4.
- Layer 3 is ordered by plugin id, not by which root the plugin came from, so a user plugin
zzzbeats a first-partyaaa. The id is the plugin’s directory name. - Plugin conflicts are reported, not resolved. Two plugins setting one key differently are both named in the log, and the later one alphabetically wins.
- A user plugin may not shadow a first-party one. The system root claims the id first and a same-id directory in the writable root is skipped, with both paths named.
- A disabled plugin’s
flags.jsonis not read. - Roblox’s own client-settings document is not a layer. It is the base the resolved set is merged into, so
flags.listnever shows Roblox’s default for a key nobody overrode.
flags.list
Capability flags.read. No params. Returns an array with one object per resolved key.
value is always a string. Layers read from disk convert JSON numbers and booleans to strings, because Roblox stores every setting as a string. Only the winning value is listed. source is one of:
flags.get
Capability flags.read. Params {"key": "FFlagSomething"}.
{"status":"ok","id":2,"result":null}, an ok with a null result and not an error. A missing or non-string key is read as the empty string and also answers null. Check for null before reading .value.
flags.set
Capability flags.write. Params are a values object, not a key and a value.
flags.write reaches Cordial’s own settings
Any key beginning Cordial rides the same layering and is filtered out before Roblox’s settings document, because the engine does not know those keys. The ones that exist today are CordialGraphicsBackend, CordialPresentMode (what fps-flex writes) and CordialDeviceProfile. A plugin’s request only wins where the user’s own Graphics setting is Automatic. This is why the consent text says:
Change how Cordial itself renders and behaves. Sets Roblox FastFlags, and also Cordial’s own settings including the graphics backend and present mode. Takes effect at the next launch. Your own choices in Settings still win.Every future
Cordial* key inherits the same reach.
Two lifetimes
FFlag, FInt and FString are consumed once, during nativeInitClientSettings, roughly 100 ms into startup. Only the DFFlag/DFInt/DFString family is re-read while the client runs. That is why there are two capabilities (ADR-005).
flags.setDynamic passes the broker for a plugin holding flags.write.dynamic and lands on the catch-all:
error is deliberately not a denied.
A
DF* override in your flags.json governs about the first two seconds. The engine fetches Roblox’s own settings document 1.6 to 2.3 s in and reapplies it over the top, so any DF* key that document contains is reverted while the client is still starting. Keys it does not contain keep your value for the whole run. Measured in both directions inside one run, with a control. A startup flag is the stronger surface, not the weaker one. Why: ADR-051.Worked example
Reports what is in effect, then contributes a flag and handles the refusal. Thecall scaffold is the one from the events example.
plugin.json:
main.ts:
denied means the grant is missing: check plugin-grants.json for this profile. error means the call was allowed and the request was wrong.