Skip to main content
FastFlags reach the engine through a layered resolver in 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 zzz beats a first-party aaa. 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.json is not read.
  • Roblox’s own client-settings document is not a layer. It is the base the resolved set is merged into, so flags.list never 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"}.
A key no layer sets answers {"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:
A live write would need access to the running engine’s flag table, which ADR-001 and ADR-003 rule out. The 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. The call scaffold is the one from the events example. plugin.json:
main.ts:
The two refusals to recognise:
denied means the grant is missing: check plugin-grants.json for this profile. error means the call was allowed and the request was wrong.