Attributes — System Guarantees Matrix¶
The behavioural contract
This page defines the behavioural contract of the attribute system.
No marketing. No implication. Just guarantees — and explicit non-guarantees.
Quick Navigation¶
- Container
- Ids
- Bounds and the Sign Convention
- Reads and Combining
- Events
- Persistence
- Non-Guarantees
- Final Summary
1. Container¶
AttributeSet¶
| Aspect | Guarantee |
|---|---|
| Shape | One component per owner; consumers find it as IAttributeSource on the parent chain |
| Schema | ❌ None — no attribute id is named by the framework, ever |
| Creation | ✔ SetBaseValue with a new id creates the attribute; created ids are unbounded |
| Authored rows | ✔ Seeded on first use; unusable rows are reported and skipped, never repaired |
| Lifecycle | ✔ Works without Awake — state is created at the head of every public member, so EditMode and editor tooling see player behaviour |
| Value type | float; integer consumers convert at their adapter, visibly |
2. Ids¶
| Aspect | Guarantee |
|---|---|
| Form | Plain strings, compared ordinal, case-sensitive |
| Trimming | ✔ Leading and trailing whitespace ignored on every path — " speed " and "speed" are one attribute |
| Blank | ❌ Refused loudly on write, false on read; nothing is ever stored under a blank id |
| Duplicate authored rows | First wins, later reported and skipped |
3. Bounds and the Sign Convention¶
| Aspect | Guarantee |
|---|---|
| Negative values | ✔ Permitted everywhere |
| Negative bounds | ✔ Permitted everywhere |
| Non-finite values | ❌ Refused loudly; the stored value is unchanged |
| Non-finite bounds | Dropped per side, reported |
| Inverted pair (min > max) | Reported and ignored as a pair — never swapped, never clamped into something unauthored |
| Authored base outside its own bounds | Seeds clamped, and reported — a rewrite of an authored number is never silent |
| Where clamping happens | ✔ At every base write, and on effective values after the combiner runs |
| Runtime-created ids | Unbounded — bounds are authored configuration |
| Restore | ✔ Saved values clamp into the bounds that exist now |
Nothing is silently normalised
The cautionary tale is Currency's cap rule, which quietly rewrites a negative bound to zero. This system's posture is the opposite: a number you authored either means what it says or it is reported and inert.
4. Reads and Combining¶
| Aspect | Guarantee |
|---|---|
TryGetValue totality | ✔ Never throws — null, blank and unknown ids return false |
| No combiner | ✔ Effective value is the clamped base; providers are not consulted at all |
| Providers exist, no combiner | ✔ Explained once per component in the console |
| Provider discovery | GetComponentsInParent, inactive GameObjects included |
| Disabled provider | ❌ Does not run — unticking the box is a deliberate switch-off |
| Destroyed provider / combiner | Dropped, via a real liveness check — interface references never reach Unity's operators on their own. A destroyed code-supplied combiner releases back to the inspector slot |
| Throwing provider | ✔ Isolated and logged; the read and later providers continue, and anything it appended before throwing is rolled back with it |
| Throwing combiner | ✔ Logged; the read serves the base value |
| Non-finite combiner result | ✔ Reported once per component; the base value is served |
| Re-entrant reads | ✔ A combiner or provider reading another attribute of the same owner is supported; nested reads allocate fresh buffers |
| Cyclic derived reads | ✔ Capped at AttributeSet.MaxReadDepth; the read bottoms out on clamped base values and reports once, instead of overflowing the stack |
| Combination order | Contributions arrive in provider order; how they fold is entirely the combiner's |
5. Events¶
| Aspect | Guarantee |
|---|---|
| Payload | AttributeDelta — owner, id, before and after |
| When | ✔ Only on an actual change of the stored value |
| Creation | ✔ SetBaseValue creating an attribute raises once, with before equal to after — the id is the news, and no fabricated crossing. Authored rows seeding on first use are the starting state, not a change, and raise nothing |
| Clamped writes | ✔ The delta reports the value as stored, not as requested |
| Emit order | ✔ Designer UnityEvent first, then the C# event — pinned by test |
| Subscriber isolation | ✔ A throwing C# subscriber is logged and later subscribers still run |
| Effective values | ❌ No event, by design — computed reads have no owner of "changed" |
| Restore | ❌ Silent — state is written directly; presentation reconciles from RevSaveManager.LoadCompleted |
6. Persistence¶
| Aspect | Guarantee |
|---|---|
| What is saved | ✔ Base values only — never effective values, never contributions, never bounds |
| Double writes | ✔ No value in a save file is written by this participant and any other |
| Restore order | Default — the participant deliberately does not implement IRevSaveOrdered, because its restore touches no other system |
| Restore semantics | ✔ A rewind per owner: authored rows re-seeded, saved values on top, runtime-created ids the save does not mention removed |
| Unmentioned authored rows | ✔ Return to their authored base — adding a row to your project does not break old saves |
| Deleted authored rows | ✔ A saved id restores as a runtime-created attribute — ids are data, nothing dangles |
| Empty owners | ✔ An identified owner with no attributes still writes an entry, so "you had none" survives a load |
| Ownerless containers | ❌ Not captured — an AttributeSet without a StableId could never be matched again |
| Missing owners at restore | Skipped silently per owner; a section matching no owner is reported rather than read as success |
| Unreadable owners | Refused whole — a blank id, a non-finite value, an id named twice all refuse the owner; partial-restore reported when other owners had already landed |
| A snapshot stripped from the file | Indistinguishable from a legitimately empty owner by construction, so it rewinds to authored defaults — a stated cost of the empty-entry decision, argued on the participant |
| Payload shape | ✔ Sorted by id — identical state serialises identically |
7. Non-Guarantees¶
| Not guaranteed | Why |
|---|---|
| ❌ A stacking model | The combine step is the project's; a shipped default would contradict seams already sold |
| ❌ Effective-value change events | Pull seams have no "changed" moment the container can see |
| ❌ Thresholds, ranks or named tiers | Threshold semantics (hysteresis above all) belong to the consumer of a value, not its container |
| ❌ Canonical attribute ids | A shipped stat sheet decides what kinds of characters every game has |
| ❌ Per-context keys | Keyed by owner and id only; an (owner, context) axis would absorb whole domains by accident |
| ❌ Modifier-read performance | Provider collection walks the parent chain per read when a combiner is wired; unmeasured, and stated rather than promised |
| ❌ Attribute removal | No API today; a restore rewinding is the one way an id disappears |
Final Summary¶
What Attributes guarantees
Values keyed by owner and id, trimmed and validated loudly; bounds that clamp without ever rewriting what you authored; change events carrying both values, exactly once per change; reads that stay total under throwing project code; and a save that stores every fact exactly once, rewinding on restore.
What it will never do
Name your stats, stack your bonuses, own your thresholds, or key a value by anything except its owner.