Decision replay and state hash rules
Source IDecisionObserver.cs, QuestionFingerprint.cs, StateDigest.cs, StateHashKey.cs
Trax.Core reports every answered question and every routing to an IDecisionObserver, and asks an
IDecisionReplay for an earlier answer before it asks a decider. Both are in Trax.Core.Decisions,
both are optional, and both are found in the train's Memory, then the container.
Decisions explains why replay works this way;
Trax.Effect's implementation is in Decision Recording and Models.
IDecisionObserver
public interface IDecisionObserver
{
bool Required => false;
Task Decided(DecisionMade decision, CancellationToken cancellationToken);
Task Routed(TrackRouted routing, CancellationToken cancellationToken);
Task Refused(DecisionRefused refusal, CancellationToken cancellationToken) => Task.CompletedTask;
}| Member | Called |
|---|---|
Decided | Once per answered question, on the train's path, after the answer is checked and before any track is taken on it. |
Routed | Once per routing step, with the track taken. |
Refused | Once for each question whose live answer the step will not act on (missing, or not fitting the question), before the step fails. A cascade that ended with an answer that fits is not a refusal; a shadow's bad answer goes in its ShadowAnswer.Error instead. A refusal is never replayed. |
Required | false: what the observer throws is logged and ignored. true: a failure to record fails the step before the train acts on the decision, classified as the exception says, or Transient when it says nothing. |
An observer that cannot be resolved at all fails the step either way. An observer that cannot
record a refusal is logged, and never replaces the refusal as the reason the step failed, even
when it is Required.
DecisionMade
| Member | Description |
|---|---|
Train, RunId | The train and the run that asked. |
Question | The question as it was asked. |
Occurrence | How many times this run asked that question before, from 0. |
Fingerprint | The asking's fingerprint. |
Answer | The answer acted on. |
Decider | The decider's type, or null for a replayed answer. |
Replayed | Whether the answer was replayed rather than asked. |
Shadows | Each shadow's ShadowAnswer: Decider, Answer (null when it gave none), Agrees, Error. |
ReplayRefused | Which replay check refused a recorded answer, or null. |
StateHash | The state hash, when one was taken. |
StateType | The type of the state the hash was taken of: its runtime type, or the type the step declared when the state was null. |
QuestionType | The type the question was asked about: the enum a choice or score is between, or the marker type of a yes or no question. Null only on a record built outside a run. |
StateType and QuestionType let a host treat a decision by what it was about, matching with
inheritance, where a key would match only one name.
DecisionRefused
Train, RunId, Question, Occurrence, Fingerprint, Answer (null when there was none),
Decider, Reason and QuestionType.
TrackRouted
Train, RunId, On (the enum or marker type routed on), Track (the track's name), and
FallbackReason, set when the decision was not followed and a fallback track ran.
IDecisionReplay
public interface IDecisionReplay
{
Task<RecordedAnswer?> Replay(
string train, string runId, string key, int occurrence, CancellationToken cancellationToken);
}
public sealed record RecordedAnswer(Answer Answer, string Fingerprint)
{
public string? StateHash { get; init; }
}Replay is asked on the run's path, once per question, by the same coordinates DecisionMade
carries. It returns null to ask the decider. A host that replays stores each DecisionMade.Answer
with its Fingerprint and StateHash and hands them back exactly as stored. Whatever Replay
throws fails the step, classified as the exception says, because a replay that cannot be read
cannot be told from a run with nothing to replay. A question whose answer is replayed is not put
to the shadows.
Fingerprint
A SHA-256 over one asking of a question as the chain declares it: the step, the state's type, the kind of question, its key, its words, and every option or level with its description (or what yes and no mean). It never covers the state's value.
Replay checks
A replayed answer is checked before it is acted on. The decider is asked afresh when:
| Check | Fails when |
|---|---|
| Fingerprint | It differs from the question as asked now: the question was reworded, its options, levels or descriptions changed, or the chain changed so that another step now asks it first. |
| Fit | The answer no longer fits the question: an option renamed or removed from the enum, a scale with fewer levels, another kind of question. |
| State hash | Its StateHash differs from the hash of the state the question is asked about now, or is null. |
DecisionMade.ReplayRefused says which check refused the answer, and never quotes the recorded
answer. A recorded choice of a member the switch has no track for is replayed like any other and
takes the Otherwise track again.
| The replayed answer's state hash | What happens |
|---|---|
| Equal to the hash of the state asked about now | Replayed |
| Different, including one taken under another key or without a key | Asked afresh, with the reason in ReplayRefused |
| Null (an answer recorded before states were hashed, or about a state that could not be hashed) | Asked afresh, with the reason in ReplayRefused |
| The state asked about now cannot be hashed (below) | Asked afresh, with the reason in ReplayRefused. Hashing never fails the run. |
When the state hash key is the cause, ReplayRefused names it: an answer recorded under a key
while this run hashes without one, or the reverse, says so; a keyed hash that differs says the
state changed "or the state hash key has changed since"; and a key that cannot be resolved is
reported as that, not as a change in the state.
State hash
Taken only when needed: to check a replayed answer that passed its fingerprint and fit checks, or before the decider is asked when a decision observer is registered, so the hash an observer records is of the state the decider was given.
| Form | When |
|---|---|
k1: and the lowercase hex of an HMAC-SHA256 | A StateHashKey is registered |
s1: and the lowercase hex of a SHA-256 | No key is registered |
A keyed and an unkeyed hash never match.
What it covers
- Every instance field of the state's runtime type, public or not, and every value they hold, recursively: auto-properties, tuple items, record members, and the members of a derived type held where a base type or an interface is declared.
- Each value's runtime type, named with its assembly, and its fields base type first and by name.
- Every element of an array, inline array or fixed buffer, and each array dimension's length and lower bound.
- A framework collection as its elements and its comparers. A wrapper (a read-only wrapper, a
Collection<T>and so a keyed or observable collection, a view of a dictionary's keys) as what it wraps, with its own comparers, so a key view is written as its whole dictionary and a list of the state's own type by its fields. - A set, dictionary or bag with its elements sorted, so equal contents hash alike whatever order they were added in.
- An enum with its underlying type and its members' names and values; a local
DateTimewith its daylight-saving flag.
It covers the state's value and nothing else: what a decider reads from elsewhere is not in it. Only the hash leaves the run, never the state.
States with no hash
StateHash is null, and the state never replays, when the state:
- holds a reference cycle
- is nested deeper than 64 levels, or its encoding passes 1,000,000 values or 16 MiB
- holds a delegate (an ORM's lazy-loading proxy, for instance), a pointer or native handle
- holds a
Type, aMemberInfoor anAssembly - holds a stream, a wait handle, a task or a thread
- holds a non-generic
HashtableorSortedList, aBlockingCollection<T>, a wrapperArrayListmakes (ArrayList.Adapter,ReadOnly,Synchronized...), or a collection that enumerates by its own code - holds a
CultureInfoother than the framework's shared instance (CultureInfo.GetCultureInfo(name)orInvariantCulture);CurrentCultureandnew CultureInfo(...)give no hash - has a field that throws when read, or a collection that throws when enumerated
It is also null when the container's StateHashKey cannot be resolved.
StateHashKey
using Trax.Core.Decisions;
var stateHashKeyBytes = Convert.FromBase64String(configuration["StateHashKey"]!);
services.AddSingleton(new StateHashKey(stateHashKeyBytes)); // at least 32 bytes| Member | Description |
|---|---|
StateHashKey(byte[] key) | Copies key. Throws ArgumentException when it is null or shorter than 32 bytes. Drawn from a cryptographic random source and kept as the host keeps its other secrets. |
Trax.Core reads the key from the train's container. Hashes are compared exactly, so every process
that may repeat a run uses the same key, and adding or changing the key means each answer recorded
before it is asked afresh once. Trax.Effect's
AddDecisionRecording can supply the
key from its options or from configuration.