Skip to main content

AccessFeature

One capability a game gates: entering a chapter, buying an egg, opening a demo area. A feature is policy -- it reads facts and decides.

Every judgement lives here: which combination of facts opens a thing, what a release flag means for it, inversions, per-thing context. Facts state what is true; features decide what that is worth. That is why the same fact can grant one feature and deny another.

-- The common case needs no function.
local Chapters = AccessFeature.anyOf("chapters", { "ownsGame", "isEarlyAccessTester" })

-- Anything else declares its non-fact inputs and folds them in.
local EggPurchase = AccessFeature.new("eggPurchase", {
	facts = { "ownsGame", "isEarlyAccessTester" },
	context = {
		assetId = function(eggName) return observeEggAssetId(eggName) end,
		hasCollected = function(eggName) return observeCollected(eggName) end,
	},
	observeCompute = function(observeFacts, eggName, observeContext)
		return Rx.combineLatest({
			facts = observeFacts,
			context = observeContext,
		}):Pipe({
			Rx.map(EggHuntAccessPolicy.computeEggPurchase),
		})
	end,
})

context is for inputs that are not facts, because facts are per-player and these are per-thing: an egg's asset, whether it has been collected. Declaring them rather than closing over them inside observeCompute is what lets a report print them beside the facts -- "why is this egg refused" is answered by the context at least as often, and an input nobody can see is an input nobody can debug.

observeCompute is handed the fact observable rather than each fact value, so a feature that needs context of its own combines it once instead of rebuilding it every time a fact changes. Keep the verdict itself a pure function the pipe maps through -- that is the part worth unit testing, and it stays testable without an observable in sight.

facts is declared rather than inferred so an unresolved fact only stalls the features that actually read it. One dead mechanism should not leave the whole game unresolved.

Types

AccessFeatureCompute

type AccessFeatureCompute = (
subjectany?
) → Observable<AccessStateUtils>

AccessFeatureInput

interface AccessFeatureInput {
observeContextObservable<{[string]any}>--

the declared non-fact inputs, resolved for this subject

observeFeaturesObservable<{[string]AccessState}>--

the declared feature inputs, whole verdicts

playerPlayer?--

who this is being decided for

}

Everything a compute gets beyond its facts and its subject.

A named bag rather than more positional arguments: facts and subject are what nearly every feature uses, and the rest has already grown three times. Adding to this cannot churn a compute that does not read it.

AccessFeatureContext

type AccessFeatureContext = {[string](subjectany?) → Observable<any>}

Named inputs that are not facts, resolved from the subject.

Functions

new

AccessFeature.new(
featureNamestring,
optionsAccessFeatureOptions
) → AccessFeature

anyOf

AccessFeature.anyOf(
featureNamestring,
factNames{string}
) → AccessFeature

A feature granted by any one of its facts, unresolved while one is unanswered and nothing else has granted, denied only once every answer is in and none of them granted. See AccessStateUtils.fromFacts.

allOf

AccessFeature.allOf(
featureNamestring,
factNames{string}
) → AccessFeature

A feature granted only when every declared fact allows -- the and-of that a flag-gated grant actually is, like isEarlyAccessTester and testerEarlyAccessEnabled.

Unresolved while any of them is unanswered, refused as soon as one denies. A definite no ends it: no later answer can rescue an and-of, so waiting would be a stall with a known answer behind it.

Facts pushed on later still widen, they do not join the and: allOf(a, b) granted by a push means (a and b) or pushed. Anything else would make AccessFeature.PushFactAllowsFeature able to take access away, which is exactly the surprise it promises not to have.

noneOf

AccessFeature.noneOf(
featureNamestring,
factNames{string}
) → AccessFeature

A feature granted only when every declared fact is definitely false -- "does not own the game".

Unresolved while any of them is unanswered, which is the whole reason this exists rather than a not in a compute: inverting the value turns unresolved into true, and on a purchase gate that is offering to sell somebody something they already have.

Pushed facts widen, on the same terms as AccessFeature.allOf.

alwaysAllowed

AccessFeature.alwaysAllowed(featureNamestring) → AccessFeature

A feature nothing can gate. Useful as the always-open half of a pair, and as the thing a game registers while a real rule is still being written.

isAccessFeature

AccessFeature.isAccessFeature(valueany) → boolean

GetFeatureName

AccessFeature.GetFeatureName(selfAccessFeature) → string

GetFactNames

AccessFeature.GetFactNames(selfAccessFeature) → {string}

The facts this feature reads. A copy, so a caller cannot quietly widen what the feature depends on.

ObserveFactNames

AccessFeature.ObserveFactNames(selfAccessFeature) → Observable<{string}>

The facts this feature reads, live. Changes when something is pushed onto the feature.

PushFactAllowsFeature

AccessFeature.PushFactAllowsFeature() → () → ()--

Removes the fact from this feature

Adds a fact that also allows this feature, and hands back a function that removes it again.

This is how a feature is extended without editing it. A package ships owns-game reading a purchase; a game pushes a gamepass and a staff allowlist onto the same feature, and everything already gating on owns-game picks them up.

maid:GiveTask(ownsGame:PushFactAllowsFeature(gamePassFact))
maid:GiveTask(ownsGame:PushFactAllowsFeature(adminFact))

The fact must be registered with AccessDataService as well -- pushing says this fact grants this feature, registering says here is how to answer it. Pushing an unregistered fact fails loudly when the feature is next read, rather than silently never granting.

Pushing is strictly widening: a pushed fact can grant, never deny. Anything else would make "add a way in" able to take one away, which is exactly the sort of surprise this API should not have.

A push made on the server reaches the client -- AccessDataService replicates what each feature reads, and the client widens by name. So this is safe to call from server-only code without the two realms drifting apart on what the feature means.

PushFactNameAllowsFeature

AccessFeature.PushFactNameAllowsFeature(
factNamestring
) → () → ()--

Removes the fact from this feature

The same widening by name rather than by object, for a realm that has the name but not the fact.

This is what server-to-client replication of a push arrives through: the client learns that a fact grants a feature before -- or without ever -- having a resolver for it, and the value comes over the per-player fact replication. Prefer AccessFeature.PushFactAllowsFeature anywhere the fact is in hand, because passing the object is what makes it obvious the fact has to exist somewhere.

RequiresSubject

AccessFeature.RequiresSubject(selfAccessFeature) → boolean

Whether asking about this feature without a subject is a meaningless question.

"Can they enter a world" has no answer; "can they enter world 3" does. Anything that walks the whole registry -- the per-player tracker behind AccessPlayerBase.IsFeatureAllowed, the console dump -- leaves these alone rather than evaluating them with nothing, which at best reports a verdict nobody asked for and at worst runs a compute against a nil it was never written for.

GetFeatureInputs

AccessFeature.GetFeatureInputs(selfAccessFeature) → {AccessFeature}

The other features this one reads the verdict of.

Declared rather than reached for inside a compute, because a verdict that came from somewhere the report cannot name is a verdict nobody can explain. FeatureAccessFact does the other conversion -- feature to fact -- which collapses it to a boolean; this keeps the whole state, so a refusal for boughtAccessDisabled stays distinguishable from one for notOwned.

Returns loosely typed for the same reason the fact layers are: an array of a BaseObject-derived class does not unify with itself under the old solver, and the cascade reaches every caller.

GetContextNames

AccessFeature.GetContextNames(selfAccessFeature) → {string}

The names of this feature's non-fact inputs.

ObserveContext

AccessFeature.ObserveContext(
subjectany?
) → Observable<{[string]any}>

This feature's non-fact inputs, resolved against a subject, as one table keyed by name.

Facts are per-player, so anything per-thing -- an egg's asset, whether it has been collected -- cannot be one. Declaring those here instead of closing over them inside observeCompute is what lets a report print them beside the facts: an input nobody can see is an input nobody can debug, and "why is this egg refused" is answered by the context as often as by the facts.

Emits an empty table for a feature that declared none, rather than never emitting, so a caller can combine it unconditionally.

GetDebugState

AccessFeature.GetDebugState(selfAccessFeature) → {
featureNamestring,
facts{string},
context{string}
}

A plain snapshot of this feature, including anything pushed onto it since it was written.

ObserveCompute

AccessFeature.ObserveCompute(
observeFactsObservable<AccessFactState>,
subjectany?,
extras{
observeFeaturesObservable<{[string]AccessState}>?,
playerPlayer?
}?
) → Observable<AccessStateUtils>

Runs the feature's policy. Called by AccessDataService, which supplies the fact observable.

observeFeatures and player can only come from AccessDataService, which has the registry and knows who is being asked about. A feature declaring no feature inputs gets an empty map rather than nothing, so a compute can combine it unconditionally.

Show raw api
{
    "functions": [
        {
            "name": "new",
            "desc": "",
            "params": [
                {
                    "name": "featureName",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "options",
                    "desc": "",
                    "lua_type": "AccessFeatureOptions"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 134,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "anyOf",
            "desc": "A feature granted by any one of its facts, unresolved while one is unanswered and nothing else has\ngranted, denied only once every answer is in and none of them granted. See [AccessStateUtils.fromFacts].",
            "params": [
                {
                    "name": "featureName",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "factNames",
                    "desc": "",
                    "lua_type": "{ string }"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 173,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "allOf",
            "desc": "A feature granted only when **every** declared fact allows -- the and-of that a flag-gated grant\nactually is, like `isEarlyAccessTester` *and* `testerEarlyAccessEnabled`.\n\nUnresolved while any of them is unanswered, refused as soon as one denies. A definite no ends it: no\nlater answer can rescue an and-of, so waiting would be a stall with a known answer behind it.\n\nFacts pushed on later still **widen**, they do not join the and: `allOf(a, b)` granted by a push means\n`(a and b) or pushed`. Anything else would make [AccessFeature.PushFactAllowsFeature] able to take\naccess away, which is exactly the surprise it promises not to have.",
            "params": [
                {
                    "name": "featureName",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "factNames",
                    "desc": "",
                    "lua_type": "{ string }"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 210,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "noneOf",
            "desc": "A feature granted only when **every** declared fact is definitely false -- \"does not own the game\".\n\nUnresolved while any of them is unanswered, which is the whole reason this exists rather than a `not`\nin a compute: inverting the value turns unresolved into true, and on a purchase gate that is offering\nto sell somebody something they already have.\n\nPushed facts widen, on the same terms as [AccessFeature.allOf].",
            "params": [
                {
                    "name": "featureName",
                    "desc": "",
                    "lua_type": "string"
                },
                {
                    "name": "factNames",
                    "desc": "",
                    "lua_type": "{ string }"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 227,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "alwaysAllowed",
            "desc": "A feature nothing can gate. Useful as the always-open half of a pair, and as the thing a game registers\nwhile a real rule is still being written.",
            "params": [
                {
                    "name": "featureName",
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 278,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "isAccessFeature",
            "desc": "",
            "params": [
                {
                    "name": "value",
                    "desc": "",
                    "lua_type": "any"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "boolean"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 291,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "GetFeatureName",
            "desc": "",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 298,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "GetFactNames",
            "desc": "The facts this feature reads. A copy, so a caller cannot quietly widen what the feature depends on.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "{ string }"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 307,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "ObserveFactNames",
            "desc": "The facts this feature reads, live. Changes when something is pushed onto the feature.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "Observable<{ string }>"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 316,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "PushFactAllowsFeature",
            "desc": "Adds a fact that also allows this feature, and hands back a function that removes it again.\n\nThis is how a feature is extended without editing it. A package ships `owns-game` reading a purchase;\na game pushes a gamepass and a staff allowlist onto the same feature, and everything already gating on\n`owns-game` picks them up.\n\n```lua\nmaid:GiveTask(ownsGame:PushFactAllowsFeature(gamePassFact))\nmaid:GiveTask(ownsGame:PushFactAllowsFeature(adminFact))\n```\n\nThe fact must be registered with [AccessDataService] as well -- pushing says *this fact grants this\nfeature*, registering says *here is how to answer it*. Pushing an unregistered fact fails loudly when\nthe feature is next read, rather than silently never granting.\n\nPushing is strictly widening: a pushed fact can grant, never deny. Anything else would make \"add a way\nin\" able to take one away, which is exactly the sort of surprise this API should not have.\n\nA push made on the server reaches the client -- [AccessDataService] replicates what each feature reads,\nand the client widens by name. So this is safe to call from server-only code without the two realms\ndrifting apart on what the feature means.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                },
                {
                    "name": "fact",
                    "desc": "",
                    "lua_type": "AccessFact"
                }
            ],
            "returns": [
                {
                    "desc": "Removes the fact from this feature",
                    "lua_type": "() -> ()"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 346,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "PushFactNameAllowsFeature",
            "desc": "The same widening by name rather than by object, for a realm that has the name but not the fact.\n\nThis is what server-to-client replication of a push arrives through: the client learns that a fact\ngrants a feature before -- or without ever -- having a resolver for it, and the value comes over the\nper-player fact replication. Prefer [AccessFeature.PushFactAllowsFeature] anywhere the fact is in hand,\nbecause passing the object is what makes it obvious the fact has to exist somewhere.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                },
                {
                    "name": "factName",
                    "desc": "",
                    "lua_type": "string"
                }
            ],
            "returns": [
                {
                    "desc": "Removes the fact from this feature",
                    "lua_type": "() -> ()"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 363,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "RequiresSubject",
            "desc": "Whether asking about this feature without a subject is a meaningless question.\n\n\"Can they enter a world\" has no answer; \"can they enter world 3\" does. Anything that walks the whole\nregistry -- the per-player tracker behind [AccessPlayerBase.IsFeatureAllowed], the console dump --\nleaves these alone rather than evaluating them with nothing, which at best reports a verdict nobody\nasked for and at worst runs a compute against a nil it was never written for.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "boolean"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 428,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "GetFeatureInputs",
            "desc": "The other features this one reads the verdict of.\n\nDeclared rather than reached for inside a compute, because a verdict that came from somewhere the\nreport cannot name is a verdict nobody can explain. [FeatureAccessFact] does the other conversion --\nfeature to *fact* -- which collapses it to a boolean; this keeps the whole state, so a refusal for\n`boughtAccessDisabled` stays distinguishable from one for `notOwned`.\n\nReturns loosely typed for the same reason the fact layers are: an array of a BaseObject-derived class\ndoes not unify with itself under the old solver, and the cascade reaches every caller.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "{ AccessFeature }"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 445,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "GetContextNames",
            "desc": "The names of this feature's non-fact inputs.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "{ string }"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 454,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "ObserveContext",
            "desc": "This feature's non-fact inputs, resolved against a subject, as one table keyed by name.\n\nFacts are per-player, so anything per-*thing* -- an egg's asset, whether it has been collected -- cannot\nbe one. Declaring those here instead of closing over them inside `observeCompute` is what lets a report\nprint them beside the facts: an input nobody can see is an input nobody can debug, and \"why is this egg\nrefused\" is answered by the context as often as by the facts.\n\nEmits an empty table for a feature that declared none, rather than never emitting, so a caller can\ncombine it unconditionally.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                },
                {
                    "name": "subject",
                    "desc": "",
                    "lua_type": "any?"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "Observable<{ [string]: any }>"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 478,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "GetDebugState",
            "desc": "A plain snapshot of this feature, including anything pushed onto it since it was written.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "{ featureName: string, facts: { string }, context: { string } }"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 496,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "ObserveCompute",
            "desc": "Runs the feature's policy. Called by [AccessDataService], which supplies the fact observable.\n\n`observeFeatures` and `player` can only come from [AccessDataService], which has the registry and knows\nwho is being asked about. A feature declaring no feature inputs gets an empty map rather than nothing,\nso a compute can combine it unconditionally.",
            "params": [
                {
                    "name": "self",
                    "desc": "",
                    "lua_type": "AccessFeature"
                },
                {
                    "name": "observeFacts",
                    "desc": "",
                    "lua_type": "Observable<AccessFactState>"
                },
                {
                    "name": "subject",
                    "desc": "",
                    "lua_type": "any?"
                },
                {
                    "name": "extras",
                    "desc": "",
                    "lua_type": "{ observeFeatures: Observable<{ [string]: AccessState }>?, player: Player? }?"
                }
            ],
            "returns": [
                {
                    "desc": "",
                    "lua_type": "Observable<AccessStateUtils>"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 522,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        }
    ],
    "properties": [],
    "types": [
        {
            "name": "AccessFeatureCompute",
            "desc": "",
            "lua_type": "(Observable<AccessFactState>, subject: any?) -> Observable<AccessStateUtils>",
            "source": {
                "line": 65,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "AccessFeatureInput",
            "desc": "Everything a compute gets beyond its facts and its subject.\n\nA named bag rather than more positional arguments: facts and subject are what nearly every feature\nuses, and the rest has already grown three times. Adding to this cannot churn a compute that does not\nread it.",
            "fields": [
                {
                    "name": "observeContext",
                    "lua_type": "Observable<{ [string]: any }>",
                    "desc": "the declared non-fact inputs, resolved for this subject"
                },
                {
                    "name": "observeFeatures",
                    "lua_type": "Observable<{ [string]: AccessState }>",
                    "desc": "the declared feature inputs, whole verdicts"
                },
                {
                    "name": "player",
                    "lua_type": "Player?",
                    "desc": "who this is being decided for"
                }
            ],
            "source": {
                "line": 78,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        },
        {
            "name": "AccessFeatureContext",
            "desc": "Named inputs that are not facts, resolved from the subject.",
            "lua_type": "{ [string]: (subject: any?) -> Observable<any> }",
            "source": {
                "line": 96,
                "path": "src/access/src/Shared/AccessFeature.lua"
            }
        }
    ],
    "name": "AccessFeature",
    "desc": "One capability a game gates: entering a chapter, buying an egg, opening a demo area. A feature is\npolicy -- it reads facts and **decides**.\n\nEvery judgement lives here: which combination of facts opens a thing, what a release flag means for it,\ninversions, per-thing context. Facts state what is true; features decide what that is worth. That is\nwhy the same fact can grant one feature and deny another.\n\n```lua\n-- The common case needs no function.\nlocal Chapters = AccessFeature.anyOf(\"chapters\", { \"ownsGame\", \"isEarlyAccessTester\" })\n\n-- Anything else declares its non-fact inputs and folds them in.\nlocal EggPurchase = AccessFeature.new(\"eggPurchase\", {\n\tfacts = { \"ownsGame\", \"isEarlyAccessTester\" },\n\tcontext = {\n\t\tassetId = function(eggName) return observeEggAssetId(eggName) end,\n\t\thasCollected = function(eggName) return observeCollected(eggName) end,\n\t},\n\tobserveCompute = function(observeFacts, eggName, observeContext)\n\t\treturn Rx.combineLatest({\n\t\t\tfacts = observeFacts,\n\t\t\tcontext = observeContext,\n\t\t}):Pipe({\n\t\t\tRx.map(EggHuntAccessPolicy.computeEggPurchase),\n\t\t})\n\tend,\n})\n```\n\n`context` is for inputs that are not facts, because facts are per-*player* and these are per-*thing*:\nan egg's asset, whether it has been collected. Declaring them rather than closing over them inside\n`observeCompute` is what lets a report print them beside the facts -- \"why is this egg refused\" is\nanswered by the context at least as often, and an input nobody can see is an input nobody can debug.\n\n`observeCompute` is handed the fact observable rather than each fact value, so a feature that needs context of\nits own combines it once instead of rebuilding it every time a fact changes. Keep the verdict itself a\npure function the pipe maps through -- that is the part worth unit testing, and it stays testable\nwithout an observable in sight.\n\n`facts` is declared rather than inferred so an unresolved fact only stalls the features that actually\nread it. One dead mechanism should not leave the whole game unresolved.",
    "source": {
        "line": 47,
        "path": "src/access/src/Shared/AccessFeature.lua"
    }
}