1.0.0-beta.3 — the conformance release
Pre-releaseDecisions, attestation and identity as the rulebook defines them
Until this release the framework's decision path accepted any entry id from any caller. There was no
identity check, no tenant check, and no seam through which a host could supply one — so every host
hand-rolled its own guard, and the guard a demo host actually wrote compared the reviewer id, never
the tenant, and permitted the action when identity resolution itself failed. A demo-friendly
fail-open on unresolved identity is an authorization bypass the moment a real deployment's identity
resolution can fail, which it eventually will. Nothing recorded who approved a write either: the row
said which reviewer a card had been routed to at filing time and nothing about who decided, so a host
with any audit requirement that asks "who, specifically, approved this" had nowhere to look.
Both are closed here, in the framework, so every host gets the same answer.
Added
IDecisionAuthorizationPolicy— the host's answer to "may this principal act on this entry".
Registered withservices.AddDecisionAuthorization<TPolicy>(). The framework does the parts a host
should not have to get right and delegates the one only a host can answer. In order, before any
transition: an unresolved principal is refused withdecision-unauthorizedbefore the Docket is
read; an entry outside the caller's tenant isentry-not-found— never "forbidden", because
telling a caller that an id it may not touch exists is the leak the check is for — and the row's
own tenant is compared by the framework rather than trusted from the store, so a store with a scope
bug does not make the gate fall open; then the host's port, wherefalseand a throw both
refuse, because a callback that fell over has not said yes.DenyAllDecisionAuthorization, the default. A host that registers no port refuses every
decision rather than admitting every decision. Obviously broken, and broken in the direction that
cannot approve a write nobody was entitled to approve.AffiantWireUpValidatorrefuses at startup
when the application declares a write-capable tool and no policy is registered, so no host runs on
the deny-all by accident;AcknowledgeMissingReviewWiringdoes not waive it, for the same reason it
waives nothing else that names a declared write tool.PrincipalandDecisionContext(inAffiant.Abstractions).Principal.Memberis a
human-verified session;Principal.Serviceis a machine caller that may name the person it speaks
for and the relay assertion that carried them.DecisionContextcarries the principal, the tenant,
the conversation, the channel and the reviewer's reason — passed at the call site, never resolved
from ambient state, and with no unattributed variant to fall back on. It deliberately does not
carry the act's instant: the moment an attestation and a decision record are dated to is the one
the gate observed on its ownTimeProvider, so a caller cannot date its own agreement, and a row
cannot be back-dated inside its deadline by the caller whose lateness the deadline is about.- The attestation on the row (
DocketEntry.Attestation), written by the decision. AMember
principal attestsmember. AServiceprincipal carrying both an asserted member and a relay
assertion attestsmember-via-relay, naming the person and the relay — the record must not read
as though the person signed in directly. AServiceprincipal with neither is refused: a machine
cannot agree to a write in a person's name. The rule is structural: every attestor kind's
constructor is private and the only factory that produces amemberattestation takes a
Principal.Member, so there is no expression anywhere through which a machine caller reaches one —
a compiler rejects the shortcut before a reviewer has to notice it. - A Standing Order's approval is attested too, in the same operation that files the entry
approved:standing-ordernaming the policy and the version it fired under. There is no window in
which an approved write has no attribution. A policy that versions nothing records
"unversioned"rather than a blank, because "this policy does not version itself" and "the version
was lost" are different facts. ReviewGate.MarkExecutedAsync(entryId, outcome, detail, context)— the only path to an executed
write. The host runs its own executor against the attested row and reports what happened, once,
under a guarded compare-and-set out ofunexecuted; a second report is refused with
execution-already-recordedand the first stands. The status staysApproved: the approval
happened and is not undone by a failed write. A machine caller is admitted here and refused as a
decider — reporting an outcome is a statement of fact about work the host performed, while a
decision is an act of authority a machine may never make in a person's name.ApprovalVerdict.PolicyId/PolicyVersion, andIApprovalPolicy.PolicyId/PolicyVersion.
The chain stamps which policy spoke onto the verdict rather than trusting the policy to report
itself: a record of who approved a write has to be the framework's answer.ConversationIdentity.TenantIdand.Channel, andReviewContext.Channel/
.ConversationStartedAtso a host can supply them.AffidavitFieldValues— the store-boundary converter that reads a filed Affidavit's values
back as the CLR values they were filed as.EvidenceCardResponse.Attestation— the in-process hand-off from the call that receives a
decision to the call that writes the row, so a review a blockingFileReviewAsyncis holding open
is recorded with the same attestation the non-blocking path writes.[JsonIgnore]: it is never on
the wire and never read from a client, because a client that could name whose signature a decision
is would be the whole problem.
Fixed
- A stored Affidavit no longer scores differently from the one that was filed. Field values are
object?, so a round trip through any store handed every field back as a raw JSON element rather
than the number, string or boolean the projection put there — and a host risk scorer that
pattern-matches on a value's type then saw an unrecognised type for every field of every stored row
and fell through to its default grade. Identical content scored one way when first filed and
another way when resubmitted, which is the path that always reads the record back out. The EF
stores now read values, previous values and amendment maps back typed, and a resubmission re-reads
both the Affidavit and the preserved corrections through the same converter. docket.transitionanddecision.unauthorizedcarry what they were always meant to. The
transition event names the execution outcome and the attestation kind; the refusal event names the
principal kind and the entry point (decide,mark-executed,resubmit) and fires on every
refusal path, not only the decide one.
Changed — breaking
Pre-1.0 breaking changes are permitted by this repository's prerelease-stability policy and are
declared here, in PublicAPI.Unshipped.txt and in each package's CompatibilitySuppressions.xml.
ReviewGate.HandleDecisionAsynctakes aDecisionContext. The four fail-open overloads it
had — including the parameterless-identity one — are gone. There is deliberately no overload that
omits the principal or the tenant: an overload that defaulted them would be the fail-open this
change exists to close.// before await gate.HandleDecisionAsync(entryId, ApprovalDecision.Approved, amendments); // after await gate.HandleDecisionAsync( entryId, ApprovalDecision.Approved, new DecisionContext( new Principal.Member(currentUser.Id), // or Principal.Service(...) for a relay TenantId: currentUser.TenantId, ConversationId: sessionId, Channel: "web", Reason: reviewerReason), amendments);
ReviewGate.ResubmitAsynctakes aDecisionContexttoo, and runs the same checks: a caller
that could not have decided the entry cannot re-open it either. It now returns a
ReviewFilingResult.Decidedcarrying a refusal where it used to throwInvalidOperationException
for an entry that is not visible in the caller's tenant; a non-expired entry and a lost
resubmission race still throw.- An
IDecisionAuthorizationPolicyis required wherever a write-capable tool is declared. A host
that registers none starts refusing every decision and is refused at startup. IApprovalPolicy.EvaluateAsyncandIApprovalPolicyEvaluator.EvaluateAsynctake a
ConversationIdentity— the parameter the specification has always declared. Identity is
supplied so a policy can bind (a member-bound or tenant-bound Standing Order, an order that
trusts one channel and not another); authorizing the actor is the framework's job and is never
delegated to a policy. A policy that has nothing to bind to ignores the parameter, as the built-in
ones do.// before public Task<ApprovalVerdict?> EvaluateAsync(Affidavit affidavit, CancellationToken ct = default) // after public Task<ApprovalVerdict?> EvaluateAsync( Affidavit affidavit, ConversationIdentity identity, CancellationToken ct = default)
StandingOrderBase.PolicyIdand.PolicyVersionarepublic virtual, notprotected virtual: they now implementIApprovalPolicymembers, because a Standing Order's approval is
attributed to the policy on the Docket row. A subclass that overrode either changesprotected overridetopublic override.
Migration — the host-side ownership check comes out
A host that guards HandleDecisionAsync itself today should delete that guard and register an
IDecisionAuthorizationPolicy instead. The framework now refuses an unresolved principal before it
reads the Docket, and compares the row's tenant with the caller's itself; a host-side check that
repeats either is dead code that can only drift from the framework's. What is left for the host is
the one question the framework has no opinion about — whether this person, in a tenant that already
matched, is one of the people entitled to decide this row:
services.AddDecisionAuthorization<ReviewerOrManager>();
internal sealed class ReviewerOrManager(IMembership membership) : IDecisionAuthorizationPolicy
{
public async Task<bool> MayDecideAsync(
Principal principal, DocketEntry entry, CancellationToken ct)
{
// The tenant already matched and the principal is resolved — the gate saw to both.
var memberId = principal switch
{
Principal.Member member => member.Id,
Principal.Service { AssertedMember: { } asserted } => asserted,
_ => null,
};
if (memberId is null) return false;
return memberId == entry.ReviewerUserId
|| await membership.IsApprovalManagerAsync(memberId, entry.TenantId, ct);
}
}Then, at the decision seam, build a DecisionContext from whatever the host authenticated — and pass
Principal: null when it could not authenticate anybody, rather than inventing an id. The gate
refuses that, which is the point.
A host whose executor runs after approval reports the outcome once it knows it:
var result = await writeExecutor.ExecuteAsync(approved.AmendedAffidavit ?? entry.Envelope, identity, ct);
await gate.MarkExecutedAsync(
entry.EntryId,
result.Success ? ExecutionOutcome.Executed : ExecutionOutcome.Failed,
result.EntityId ?? result.ErrorMessage,
context,
ct);Retries are the host's business; the outcome is the Docket's, and it is recorded once.
The Affidavit as the rules define it
The record every write proposal is wrapped in now says what the specification always claimed it
said. Three defects are closed together because they are one defect seen from three angles: the
confidence number on a card could be wrong in both directions, and the record could not describe an
update at all.
Added
-
Affidavit.ProtocolVersion,Affidavit.ConversationTurnandAffidavit.CreatedAt— the three
properties the protocol's own record carries and this one did not. A record is an envelope and says
which version it speaks (SR-3); a proposal names the conversation turn it was made on, ornull
when it did not come from one; and it says when it was built. The gate stampsCreatedAtwith
its own clock as it files, the same instant the deadline is measured from, so a record that
arrives unstamped is stamped once and a record that arrives stamped keeps what it says. Nothing
reads a wall clock:Affidavitnever touches a clock, and the caller passes the instant in so a
fixture can pin it.This is what makes an accepted amendment datable. A reviewer's correction belongs to the
conversation the proposal was made in, so the tag an amendment mints carries the record's
turn; before this the typed path had nowhere to read one and carried the amended field's own turn
instead — the turn on the tag being replaced, which says when the machine produced the value the
person is correcting. The two mint sites (AffidavitAmendments.Applyand the canonical document
path) now agree by construction, and a test pins them against a record that states a turn, so a
drift fails a test instead of producing a Docket row and an execution grant that disagree about
the same decision.Breaking: the positional constructor and
DeconstructofAffidavitgain three parameters at
the end, all defaulted —new Affidavit(...)with the nine original arguments still compiles, and
a positional pattern match with nineoutparameters does not.Affidavit.Creategains
conversationTurnandcreatedAtas optional trailing arguments. A host with its own
IAffidavitProjectionshould pass both; a host that passes neither gets a record the gate stamps
and anullturn. -
A Docket entry id is derived the way the protocol derives it (GT-4). The material is the
tenant, the conversation, the tool name and the canonical form of the operation and its
arguments — withsupersedespresent only when the proposal replaces a row — digested with
SHA-256 and laid out as a version-8 UUID (Affiant.Core.Services.EntryIdDerivation). It was
derived from the canonical form of the Affidavit instead, which is not the same material: the id
travels inside the record, in thereviewer-actbinding an accepted amendment mints, so it is
inside the content hash an execution grant binds to, and two implementations that derived
different ids for the same proposal disagree about which row a proposal is. Three ids produced
by the protocol's reference implementation are pinned as vectors in
EntryIdDerivationTests.A resubmission's id is derived too — the same material plus the id of the row it replaces,
which is the one case GT-4'ssupersedesclause exists for and was the one path that minted a
random GUID. Two implementations now agree about the identity of a resubmitted row as well as a
first filing.WriteProposalgains an optionalArgumentsand an optionalOperation, which is how the
material reaches the gate. The operation is the host's own declaration — its shape, the entity it
names and the fields it proposes, in the declared order — so a projection that reordered fields
cannot change which row a proposal is; a caller that declares none leaves the gate to read it off
the record (ProposedOperation.From), which is what a resubmission does, having only the stored
record to read.
Every seam in this framework fills the arguments —ReviewGateFilterattaches the invocation's own arguments
— and a host callingReviewGate.FileForReviewAsyncdirectly should pass them too. What a host
must know: two proposals carrying no arguments that differ only in a field's value are, by
that material, the same proposal, so the second replays the first's row. Pass the arguments the
model made the write from, or supply your ownReviewContext.EntryId. -
The canonical form is the PROTOCOL's record, not this framework's.
CanonicalSerializertakes
its form over the ten properties the rulebook's Affidavit schema defines and its byte vectors
pin — protocol version, conversation turn and created-at instant among them. This framework's
four extras are not part of it: the record's warnings and confirmation verdict, and a field's
closed set and pattern, all of which the protocol keeps on the card envelope, where this
framework's card also carries them. The operation is written in the protocol's two-valued
vocabulary (create/update) rather than this framework's four-valued one. Two
implementations that build a record from the same facts now produce the same bytes and the same
digest, which is the whole of SR-1: an execution grant minted by one validates against the other.
This changes the hash of every record, so a grant minted by1.0.0-beta.1does not validate
against a record canonicalised by this release; nothing shipped mints one yet. -
Every tag the framework mints says when it was minted, and says where the value came from in
the protocol's own words.ProvenanceTag.FromInferencetakes the instant (the v0.1 tag requires
one) andTaskInferenceStepstamps it from the injectedTimeProvider; the note a tag carries
for a value read literally out of the turn, and for one the model reasoned to, is the protocol's
phrasing, because a note is part of the record a hash is taken over and two implementations that
worded it differently could never agree on a digest. Anutterance-spanbinding's digest is the
canonical form's own — 64 lowercase hexadecimal characters and no prefix. -
A value keeps the JSON type the port reported it as. The inference step read every scalar as
text, so a number reported as40was filed and shown as"40". A field'skindis a rendering
hint for a reviewer surface, not a licence to re-type the value. -
Affidavit.PopulatedConfidence(float?) andAffidavit.EmptyFieldCount(int). A safety
number that reads0tells a reviewer nothing about how much of the record is blank or how good
the populated part is.PopulatedConfidenceis the minimum over the fields that are populated —
null, not0, when none is, because "there is nothing populated to be confident about" is a
different statement from "the populated fields are worthless".EmptyFieldCountis how many
proposed fields readEmpty. A host policy floor predicates on these two; the aggregate stays the
safety number, and neither the framework nor a policy defines a threshold on it. -
AffidavitConfidence.Compute(fields)— the one implementation of all three numbers, used at
filing and again on every accepted amendment.Affidavit.Create(...)builds a record with
them computed, andaffidavit.WithFields(...)recomputes them; producers should reach for
those rather than passing numbers of their own. -
IPreviousValueSource(GetPreviousValuesAsync(entityType, entityId, ct)) and
services.AddPreviousValueSource<TSource>()— the host port the built-in projection asks, on
an update-shaped operation only, for the values the write replaces. More than one may be
registered; they are consulted in registration order and the first non-nullanswer wins
(nullmeans "not mine, ask the next"; an empty map is a real answer). -
ProvenanceTag.BindingandProvenanceBinding— what an auditor looks at to check a
value, as a fixed set of five kinds (UtteranceSpan,ReviewerAct,FormInput,ExternalRef,
ComputationRef), each with its ownRefshape, travelling as{ "kind": …, "ref": { … } }
with the names pinned by attribute so the same bytes read the same way on any transport.
ProvenanceTag.IsBoundandProvenanceTag.RequiresBinding(source)say whether a tag points at
anything and whether its grade ought to. -
AffidavitAmendments.Apply(affidavit, amendments, entryId, decisionAt, reviewerId)— the one
implementation of what an accepted correction does to the record, and
ReviewOutcome.Approved.AmendedAffidavit, which carries it back from the gate. -
ProvenanceTag.Beats(incumbent)— the one implementation of the merge comparison, now called
byProvenanceChain.Merge, by the schema-driven projection and byTaskInferenceStep, which each
stated it separately before. -
Operation.IsUpdateShaped(operationType)— the predicate that decides whether an operation
names an entity, accepting"WriteUpdate"and the protocol's own"update". -
InferenceFixtureCase.EntityId— the entity an update-shaped compliance case targets.
Changed
Affidavit.AggregateConfidenceis the minimum, not a mean. It is the minimum over every
proposed field's current tag, with anEmptyfield counting as0whatever its tag says — so it
is0if and only if some proposed field has unknown provenance. The shipped projection computed
the arithmetic mean over the non-Emptyfields, which let a ten-field record with nine unknown
fields and one at1.0report a perfect1.0. (Closes #56.)- The built-in projection can produce an update-shaped Affidavit.
SchemaDrivenAffidavitProjectionfillsEntityIdfrom its newentityIdargument and each
field'sPreviousValuefrom the registeredIPreviousValueSource. It previously hard-coded both
tonull, so every Affidavit it built was create-shaped and the promise that a field's previous
value shows exactly what is changing could not be met without a host writing a complete
replacement projection. (Closes #57.) - An accepted amendment recomputes the three numbers. The gate folds the reviewer's corrections
into an amended Affidavit that travels beside the proposal —DocketEntry.Envelopestill holds
the record the reviewer was shown — with the amended field's current tagUserStatedcarrying a
reviewer-actbinding naming the decision, appended on top of the chain so the machine's
pre-correction tag is preserved beneath it. Nothing recomputed the numbers before, so a card could
show a corrected value under a number that was never about that value. (Closes #74.) - A cleared field follows the field-list rule rather than taking the reviewer's tag. A cleared
mandatory field stays present and readsEmptyat confidence 0; a cleared optional field leaves
Fieldsentirely. Writing the reviewer's1.0over an emptied field would make the numbers rise
as a reviewer wiped the record. - Confidence is clamped into
[0, 1]byProvenanceTagitself, and anEmptytag always reads
0. A producer reporting1.4,-0.2orNaNgets1,0and0. The clamp lives on the
record rather than at each mint site so no caller can route around it — the inference step in
particular passed a model-reported confidence through untouched. - The projection's field list is checked, not assumed. It must cover the strategy's declared
projected fields exactly — every one present, no other present, none twice — and throws naming the
discrepancy otherwise. AffiantWireUpValidatorrefuses at startup when a registered write tool declares an update
operation and noIPreviousValueSourceis registered, naming the tools. A create-only host is
unaffected. Like the other missing-contract checks, it is downgraded to a warning by
AffiantCoreOptions.AcknowledgeMissingReviewWiring.ProvenanceChain.Mergenow delegates its comparison toProvenanceTag.Beats; behaviour is
unchanged.ProvenanceChain.Appendis documented as what it always was — the unconditional
supersede a reviewer's act needs, which is not a confidence contest it might lose.
Removed
ProvenanceTag.FromUser(string fieldName)— replaced by
FromUser(string fieldName, ProvenanceBinding? binding).ProvenanceTag.FromInference(string fieldName, float confidence)— replaced by
FromInference(InferenceSource source, string fieldName, float confidence, ProvenanceBinding? binding).
Upgrade notes (breaking changes; pre-1.0 breaks are permitted and declared)
Affidavitgains two required constructor parameters.PopulatedConfidence(float?) and
EmptyFieldCount(int) sit immediately afterAggregateConfidence.
Migration: replace hand-written construction with
Affidavit.Create(operationType, entityType, entityId, fields, warnings, requiresConfirmation),
which computes all three from the fields. If you must keep the constructor, pass
AffidavitConfidence.Compute(fields)'s three values. They are required rather than defaulted on
purpose: a default of(null, 0)would quietly claim "no field is empty" on every existing
record. A payload persisted before this release deserializes withPopulatedConfidencenull and
EmptyFieldCount0; rows written from now on carry the real values.ProvenanceTag.FromUserrequires a binding argument. Migration: pass the artifact the
claim rests on —new ProvenanceBinding.FormInput(new FormInputRef("email")),
new ProvenanceBinding.UtteranceSpan(...),new ProvenanceBinding.ReviewerAct(...)— or
binding: nullwhere there is genuinely nothing to point at. An unboundUserStatedtag is
still recorded exactly as claimed; it is the weakest form of the strongest grade, and a policy
is entitled to refuse to rest on it.ProvenanceTag.FromInferencetakes anInferenceSourcefirst. Migration:
FromInference("Field", 0.6f)becomes
FromInference(InferenceSource.Inferred, "Field", 0.6f), orInferenceSource.Conversationwhen
the value was literally present in the turn. The enum has exactly two members, which is the
point: the inference path now has no way to nameUserStated,ExternalorComputed, so the
restriction is structural rather than a convention.ProvenanceTaggains a fifth positional parameter (Binding, defaultednull). Existing
construction sites compile unchanged; a positional deconstruction of four elements does not.IAffidavitProjection.Projectgains a fourth parameter (string? entityId = null). Callers
compile unchanged; a host that implements the interface must add the parameter. Migration: pass
the entity an update-shaped operation targets, andnull(or nothing) for a create. The built-in
projection refuses either mismatch — an update with no entity id, or a create that names one —
rather than filing a record whose shape contradicts its own operation.ReviewOutcome.ApprovedgainsAmendedAffidavit(defaultednull). Existing construction
andis ReviewOutcome.Approvedmatching are unchanged; a positional deconstruction of one
element is not.InferenceFixtureCasegainsEntityId(defaultednull). A compliance fixture whose tool
declares an update operation must set it; the harness now reports a fixture failure naming the
tool rather than silently projecting the case as a create.- A host with update-shaped write tools must register an
IPreviousValueSourceor the
application fails at startup. Migration: implement the interface over your own store and call
services.AddPreviousValueSource<TSource>(). A host that has been working around the create-only
projection with its ownIAffidavitProjectioncan keep it and register a source, or drop the
replacement and use the built-in one. - A host
IWriteExecutorthat stamped reviewer provenance by hand should stop. Use the amended
Affidavit the gate returns onReviewOutcome.Approved.AmendedAffidavit, or call
AffidavitAmendments.Apply— one fold, one answer.
The telemetry-key registry
Added
-
The telemetry-key registry (
Affiant.Abstractions.Telemetry.TelemetryKeys, plus an embedded
telemetry-keys.jsonconforming to the Affiant protocol'stelemetry-key.schema.json). Every
event the gate emits is now named in one versioned place, with the attribute names each event
carries. Operators build alerts on these names, so a key is never renamed and never removed —
only deprecated; a test enforces that against a snapshot list, and a second test asserts every
emitted name and attribute is in the registry. -
The nine v0.1 keys, emitted at the seams that exist today:
affidavit.filed—ReviewGatefiles a Docket entry (created: falseon an idempotent replay).affidavit.refused.substance— an Affidavit that swears to nothing, detected by
SchemaDrivenAffidavitProjection. This release reports; the runtime refusal follows.coverage.refused— a tool the gate cannot intercept, refused at wire-up byHostedToolAudit
inAffiant.AgentFrameworkandAffiant.Extensions.AI.docket.transition— a Docket entry changed state, emitted only by the caller whose own guarded
write affected the row, withfrom,to,decision.kindandamended.docket.expired— the sweep expired a pending entry.decision.unauthorized— a decisionReviewGate.HandleDecisionAsyncrefused, with the reason
(entry-not-found,decision-not-pending,decision-expired,decision-lost-race).standing-order.fired/standing-order.blocked— a Standing Order approved a write with no
person present, or was not honoured (blocked.reason: risk-above-thresholdtoday).policy.invalid— a host policy whoseEvaluateAsyncthrew, or an unusable review deadline.
Attributes carry field names, never field values, and use OpenTelemetry's
gen_ai.tool.name,
gen_ai.conversation.idandgen_ai.operation.namewhere a standard name exists. An attribute
this release cannot yet know is absent rather than guessed. -
affiant.docket.pending— an observable gauge reporting Docket entries awaiting review, by
tenant, registered byAddAffiantCorewhenEnableObservabilityis set. A metrics scrape never
reads the store: the gauge returns its last sample and refreshes in the background at most once
every 15 seconds, and reports at most 100 tenant series with the tail summed into__other__.
Closes the gap where the first symptom of an unbounded review queue was database load, because
nothing on a dashboard could be alerted on. -
AffidavitSubstance.DescribeFailureinAffiant.Abstractions.Models— the substance rule
(GT-3) as one shared predicate: no fields, no field carrying provenance other thanEmpty, or a
value asserted underEmptyprovenance. One copy for the projection's telemetry, the compliance
harness's test-time check, and the runtime refusal to come. -
StandingOrderBase.PolicyId/PolicyVersion— overridable, so a host that names or versions
its policies gets those names onstanding-order.firedandstanding-order.blockedinstead of
the type name.
Changed
AffiantWireUpValidatornow refuses an unusable review deadline. An
AffiantCoreOptions.DefaultDocketTtlunder one millisecond, or large enough to overflow the
ExpiresAtstamp, throwsAffiantStartupExceptionat startup and emitspolicy.invalid. It
previously started normally and filed every entry already past its deadline, so every review
"timed out" with no error anywhere. There is no acknowledgment switch for this one: a host can
knowingly run without a review loop, but no host means a deadline of zero.- A host approval policy whose
EvaluateAsyncthrows now emitspolicy.invalidbefore the
exception propagates. The throw is not swallowed — the chain still fails closed.
Deprecated
affidavit.projected— superseded byaffidavit.filed, emitted byReviewGatewhen the
Affidavit becomes a Docket entry, and byaffidavit.refused.substancefor the hollow-Affidavit
case at the same projection seam. It is still emitted alongside for this release so an existing
alert does not go dark on upgrade, and is removed in the release after1.0.0-beta.3. The
constant isAffiant.Core.Observability.DeprecatedTelemetryKeys.AffidavitProjected, marked
[Obsolete]with the replacement named. The framework's other event names —
affiant.tool_error,affiant.review.filing_failed,affiant.review.broadcast_failed,
affiant.extractor.failedand theinference.*family — are not deprecated: they name things
the registry does not cover, and they keep their names.
The gate pipeline in protocol order
The gate now runs the steps in the order the rules fix — substance refusal → the approval-policy
chain → the deadline stamped from what the chain returned → filed — and refuses, rather than skips,
three wirings it cannot run. Four defects close together because they are one defect seen from four
angles: the gate did things in an order that made two of its own rules unreachable, and passed a
write through when it could not do them at all.
Changed
- The pipeline order is fixed.
ReviewGate.FileForReviewCoreAsyncpreviously filed the Docket
row first, with a deadline computed from one process-wide default, and evaluated the approval
policy afterwards. It now refuses a proposal that swears to nothing, then walks the policy chain,
then stamps the deadline from what the chain returned, then files. Why it matters: in the old
order a policy could not name a review window at all — the window was already stamped by the time
the policy spoke — and nothing checked whether the proposal swore to anything before a reviewer was
asked about it. - An idempotent re-file keeps the entry's existing deadline. A file with an
EntryIdthat
already exists returns that entry's state and, while it is still pending, re-broadcasts that
entry's card with its existingExpiresAt. It previously broadcast a card carrying a freshly
computed deadline while the row kept its original, so a reviewer could be shown a deadline the
record does not hold. ReviewGateFilterfails closed (closesSakwala/affiant#75). Three branches that returned
quietly at debug-log level — noIReviewContextProviderregistered, no review context available
for this call, noReviewGateregistered — left the raw proposal as the tool's visible result, so
the model was free to report an unfiled, unreviewed write as done. All three are now refusals
carryingwireup-invalid. A tool the framework's registry declares write-capable that returns
something other than a proposal is refused too, rather than skipped. This is the failure mode for
exactly the call sites that most need the gate: a queue consumer, a cron trigger, a background job.AffiantWireUpValidatorrefuses a host that declares a write-capable tool and registers no
IReviewContextProvideror noReviewGate— at startup, before any turn.
AffiantCoreOptions.AcknowledgeMissingReviewWiringdoes not downgrade these two: it exists for
a host deliberately running the read and inference half with no review loop, and a host that has
declared a write-capable tool is, by its own declaration, not that host. There is no option that
turns the gate off for a tool it covers.- A Standing Order held back by its risk ceiling degrades instead of vanishing.
StandingOrderBase.EvaluateAsyncreturnednullwhen the host's score was above the threshold,
letting a later policy speak as though the order had never fired. It now returns a verdict
requiring reviewer confirmation, with the reason on the record.
Added
ApprovalVerdict, andIApprovalPolicy.EvaluateAsyncreturns one rather than a bare
ReviewRequirement. It carries the requirement in force, this write'sTimeToLive, a one-line
Reasonfor the reviewer's card, the stableBlockedReasoncode when a Standing Order was held
back, andDegradedFrom. A bareReviewRequirementconverts to one implicitly, so a policy with
nothing to say about the deadline still reads as one line.IApprovalPolicy.DefaultTimeToLiveandIApprovalPolicy.DeclaredInputs— both default
interface members, so an existing implementation needs no new code. The deadline is the verdict's,
else the policy's default, elseAffiantCoreOptions.DefaultDocketTtl.- Runtime substance refusal (
substance-refused). A proposal with no fields, with every proposed
field taggedEmpty, or with a value asserted underEmptyprovenance is refused at the gate —
not filed, not counted, not broadcast — and the refusal is the tool's error result.0,false,
an empty array and an empty object are values; onlynulland a blank string are empty. The check
previously existed only inComplianceHarness, which runs in an adopter's own test suite and never
in production; the harness keeps it, and the gate now has one too. - Two policy faults are refused at evaluation with nothing filed (
wireup-invalid): a verdict
carrying a review window that is not a deadline, and anEvaluateAsyncthat throws. Both raise
AffiantPolicyExceptionafter emittingpolicy.invalid. The throw is not swallowed — a chain that
cannot answer must not fall through to a weaker requirement. - A Standing Order is never honoured while a proposed field marked mandatory reads
Empty
(mandatory-field-empty), nor while a provenance grade the policy predicates on points at
nothing (unbound-declared-input). Both degrade the verdict to reviewer confirmation, keep the
policy's own review window — the degrade changes who decides, not when the window closes — and name
themselves onstanding-order.blocked. The checks run in a fixed order: the empty required field
first (it depends on nothing the policy declared, so a host's risk scorer is never spent on a
proposal with a hole in it), then the binding check, then the risk comparison. An optional field
leftEmptydoes not hold a Standing Order back; a host that wants it to predicates its own policy
onPopulatedConfidenceorEmptyFieldCount. AffiantRefusalExceptionwithAffiantSubstanceExceptionandAffiantPolicyException;
StandingOrderGuardandStandingOrderGuardrails(the two host-independent checks as one
shared implementation, so the base class, the chain and a fixture cannot drift);ReviewDeadline
(what counts as a review window, held to one definition by the wire-up validator and the chain);
ToolErrorCodes.SubstanceRefusedandToolErrorCodes.WireUpInvalid.
Fixed
Sakwala/affiant#58— the deadline was stamped from one global default before the policy chain ran.Sakwala/affiant#60— the hollow-Affidavit check ran only inComplianceHarness, never at runtime.Sakwala/affiant#75—ReviewGateFilterfailed open silently when unwired.
Upgrade notes (breaking changes; pre-1.0 breaks are permitted and declared)
IApprovalPolicy.EvaluateAsyncreturnsTask<ApprovalVerdict?>(was
Task<ReviewRequirement?>). Migration: change the return type. The bodies usually need no other
change — aReviewRequirementconverts to anApprovalVerdictimplicitly. A
Task.FromResult<ReviewRequirement?>(x)becomesTask.FromResult<ApprovalVerdict?>(x).IApprovalPolicyEvaluator.EvaluateAsyncreturnsTask<ApprovalVerdict>(was
Task<ReviewRequirement>). Migration: read.Requirementat the call site, or take the whole
verdict — it is what carries the deadline and the degrade reason.- A
StandingOrderBasesubclass held back by its ceiling now returns a verdict, notnull. A
host chain that relied on a later policy speaking after an over-threshold order will now stop at
the degraded verdict — which asks a person, the safe direction. Migration: if a later policy was
meant to have the final say, order it before the Standing Order. - A host that declares a write-capable tool must register an
IReviewContextProvideror the
application fails at startup, whateverAcknowledgeMissingReviewWiringsays. Migration: register
one, or declare the tool a read withservices.AddAffiantReadTool(...)if it genuinely does not
write. - A tool whose Affidavit swears to nothing now fails at run time instead of filing a card a
reviewer could approve. Migration: fill the fields the tool declares and tag each with where its
value came from. A compliance fixture that asserted a filed entry for a hollow proposal now asserts
the refusal. - A write tool declared write-capable that returns a non-proposal result is refused, where it was
previously passed through. Migration: return aWriteProposal, or declare the tool a read. - A tool that writes inside its own body is outside the guarantee (GT-6) — no filter and no
wire-up check can see it. This is stated, not fixed: the framework guarantees only that such a tool
cannot commit through it.
One clock, and the Docket row as the rules define it
Added
- One injectable time seam. Every framework component that needs the current instant now takes a
System.TimeProviderand reads it there —ReviewGate(a filing'sCreatedAt/ExpiresAt, the
late-decision deadline check, a resubmission's proposal instant), the in-memory, SQLite and
PostgreSQL Docket stores, the in-memory, SQLite and PostgreSQL chat-session stores,
DocketExpiryService(its tick and itsnow),ToolErrorFilter,ReviewGateFilter,
ManualToolInvoker, and the three inference completion ports' today's-date prompt line. Not one
DateTimeOffset.UtcNoworDateTime.UtcNowcall remains undersrc/.AddAffiantCoreregisters
TimeProvider.SystemwithTryAddSingleton, andAddAffiantDocket/
AddAffiantEntityFrameworkdo the same so either package stands alone; a host or a test that
registers its own provider wins. Every constructor parameter is optional and defaults to
TimeProvider.System, so a host that changes nothing sees no change in behaviour. AffiantDocketOptions, withExpirySweepBatchSize(default100), settable through
AddAffiantDocket(d => d.ExpirySweepBatchSize = …).- The Docket row records what happened to a write, not only that it was proposed.
DocketEntry
gainsExecution(Unexecuted | Executed | Failed, non-null exactly when the row isApproved),
ExecutionDetail,Decision({ Kind, Reason, At }),Attestation({ By, At, EntryId }with
the three attestor kinds —Member,MemberViaRelay,StandingOrder),Blocked(a
RequirementNotImplementedorCoverageRefusedmarker),CompositeRef,AmendedAffidavit,
PreservedAmendments({ Amendments, At, By }),Supersedes(paired withResubmittedToas
Lineage),DecidedAt,ToolNameandProtocolVersion. Every one of them is a later fact
appended beside what the row already held: the Affidavit as proposed is never edited, and an
accepted amendment is written asAmendedAffidavitnext to it. - The Docket store contract, as the rules define it.
IDocketStoregains
TransitionAsync(entryId, scope, expected, patch, ct)— a guarded compare-and-set answering
Transitioned | AlreadyDecided | Expired | NotFound;
PreserveAmendmentsAsync(entryId, scope, amendments, act, ct)— the amendments a refused late
decision carried, appended with that decision's own instant and principal;
RecordExecutionAsync(entryId, scope, outcome, detail, expected, ct)— the host's execution
report, accepted once, out ofUnexecuted;RecordSupersessionAsync;MarkBlockedAsync;
ExpireDueAsync(now, scope, limit, ct)— a bounded sweep that reports whether more remain;
ListPendingAsyncandListApprovedUnexecutedAsync— cursor-paged in filing order;
ApplyRetentionAsync,PurgeTenantAsyncandExportAsync.DocketScopenames the tenant and
optionally the conversation;DocketScope.EntireStoreis the host's own maintenance scope and is
refused by every member that moves a row. DocketRow— the row semantics all three backends share: the read-time deadline projection,
scope matching, patch validation and application, the retention age-from instant, and the
approved-unexecuted predicate.DocketCursor— the opaque page cursor the shipped stores hand
out and a custom store may reuse.DocketRehydration— the fixed order a reconnecting client is given its Docket back: pending
entries first, then approved entries whose write has not been reported, each in filing order and
paged behind one cursor.SessionRehydratorreads it, andRehydrationResultgains
ApprovedUnexecutedEntries.ReviewOutcome.Refused(DocketId, Code, Detail)andDocketRefusalCodes— the gate now
says why it refused an act rather than reporting four different things as an expiry.DecisionAct— who decided, in which tenant, when and why, passed to
HandleDecisionAsync. A late decision's amendments are preserved only when the act names who
made them.AffiantDocketOptions.ExpirySweepBatchesPerTick(default10) and
AffiantDocketOptions.SweepScope(default the whole store) — the second bound on a sweep
tick, and the scope a partitioned deployment narrows it to.
Changed
ReviewGatefollows the rules the row now carries. Filing writes the tool name and the
protocol tag. An idempotent re-file returns the existing entry and re-broadcasts its card with the
entry's existing deadline — a replay no longer refreshes it, which is what let a retrying agent
hold a card open indefinitely. AMultiPartyorReferralRequiredverdict files the entry
Pendingwith aRequirementNotImplementedmarker and refuses the write, instead of routing
MultiPartyto the single-reviewer branch (a joint approval requirement satisfied by one click)
or writing aDeferredstatus for a transition no implementation has run. Every decision on a
blocked entry is refused. A decision on a non-pending entry, one that lost a race, and one that
arrived after the deadline are three distinct refusals.ResubmitAsyncprefills the new proposal
from the superseded row's preserved amendments and writes the lineage on both rows.DocketExpiryServiceis a thin host-side scheduler. It callsExpireDueAsyncin batches until
the store says no more remain or the per-tick cap is reached, notifies only for the rows its own
write transitioned, and pages its warning and re-broadcast phases. Every decision about what
expires is the store's.IDocketStore.ListExpiredAsynctakes alimit—ListExpiredAsync(expiresBeforeUtc, limit, ct)— and returns at most that many due entries, oldest deadline first, so one expiry sweep tick
transitions at mostAffiantDocketOptions.ExpirySweepBatchSizeentries and a backlog drains
across ticks instead of loading the whole Docket. Breaking for a host with its own
IDocketStore: add the parameter, order byExpiresAtascending, and apply the limit. This is
the bound only — the protocol's scoped, cursor-pagedexpireDue(now, scope, limit)with its
more-remain signal arrives with the release that reshapesDocketEntry.ReviewGate.HandleDecisionAsynctests the expired case before the general already-resolved
case, and its deadline comparison is now inclusive: a decision arriving at exactlyExpiresAt
is late, where before only one arriving strictly after it was. A host that timed a decision to the
millisecond of the deadline getsExpiredwhere it previously gotApproved.ReviewGate.ResubmitAsynccommits the expiry transition before claiming the entry, so the
first resubmission of an entry whose deadline has passed but whose sweep has not run succeeds
instead of failing its own guard.
Fixed
- Expiry reads as a state, at the store boundary. A Docket entry whose
ExpiresAthas passed is
reportedExpiredbyGetDocketEntryAsync— and is absent fromListPendingBySessionAsyncand
ListAllPendingAsync— whether or not the expiry sweep has run, on an inclusive boundary. The
persisted row staysPendinguntil the sweep (or a decision, or a resubmission) commits the
guarded transition, so nothing about the compare-and-set contract changes; what changes is that a
read no longer reports an entry as awaiting a reviewer when its window has closed, and a
reconnecting session no longer replays a card that has run out of time. Breaking for a host
with its ownIDocketStore: apply the same projection at your read boundary.
Breaking changes and how to upgrade
Pre-1.0 breaking changes are permitted by this repository's own stability policy and are declared
here. Nothing below changes what a conforming host already does; each is a change to a contract.
IDocketStoregains twelve members —TransitionAsync,PreserveAmendmentsAsync,
RecordExecutionAsync,RecordSupersessionAsync,MarkBlockedAsync,ListPendingAsync,
ListApprovedUnexecutedAsync,CountPendingAsync,ExpireDueAsync,ApplyRetentionAsync,
PurgeTenantAsync,ExportAsync— and loses four:ListExpiredAsyncandMarkExpiredAsync,
both superseded byExpireDueAsync, which finds the due rows and commits their transitions under
one guard; andUpdateReviewStatusAsyncandUpdateAmendmentsAsync, superseded by
TransitionAsync. Those two took an entry id and nothing else: no tenant scope, no expected
status, no attestation — so anything holding the store could write any status onto any row in any
tenant, which is the whole of what the decision path checks, bypassed by one call.
A host with a customIDocketStorewill not compile until it implements them. That is
deliberate: the guarded compare-and-set, the once-only execution report and the bounded listings
are the properties the rules are about, and a default implementation that quietly did the wrong
thing would ship a store that looks conforming and is not.Affiant.Docket's in-memory store is
the reference implementation to read; the shared row semantics are public inDocketRowand the
page cursor inDocketCursor, so an implementer writes queries, not rules.ListPendingBySessionAsyncandListAllPendingAsyncare deprecated (AFFIANT0001): neither
is paged and neither is tenant-scoped. Replace with
ListPendingAsync(DocketScope.Conversation(tenantId, sessionId), page, ct)and
ListPendingAsync(DocketScope.EntireStore, page, ct). They are removed in the release after
this one.DocketEntry.ReviewerUserIdis deprecated (AFFIANT0001) in favour of
DocketEntry.Attestation, which can say how the claim was made — a person, a person through a
relay, or a Standing Order — whereReviewerUserIdcan only name one id. Removed in the release
after this one.ReviewGate.HandleDecisionAsyncreturnsReviewOutcome.Refusedwhere it used to return
ReviewOutcome.Expiredfor a decision on a missing entry, a decision on an already-decided
entry, a decision that lost a race, and a decision on a blocked entry. A host that branched on
Expiredfor any of those adds aRefusedarm and readsCode;ReviewOutcome.Expired. AmendmentsPreservedis replaced byRefused.Detail == "amendments-preserved".- A
MultiPartyorReferralRequiredverdict returnsReviewOutcome.Refused, not
ReviewOutcome.Approved(via a single reviewer) orReviewOutcome.Referral. No entry is written
ReviewStatus.Deferredany more. A host that treatedReferralas an escalation hand-off should
read theBlockedmarker on the row instead; multi-party approval is composed above the gate
until the protocol defines it. ReviewGate.RebroadcastPendingCardsAsynctakes a tenant id —
RebroadcastPendingCardsAsync(sessionId, tenantId, ct)— because the listing it reads is
tenant-scoped.- A late decision's amendments are preserved only when the caller names who decided. Pass a
DecisionActwithDecidedByset. They are also written toPreservedAmendmentsrather than to
Amendments: what an approval accepted and what a refused caller typed are different facts,
and a resubmission that presented the second as the first would show a refused caller's
corrections as an approval's. ReviewGate.HandleDecisionAsync's optional parameters are now explicit overloads. Existing
call sites keep compiling; a call that relied on named arguments pastamendmentsdoes not.- The store refuses what the gate refuses, on all three backends.
FileDocketEntryAsynctakes
a row that isPendingand nothing else: a decided row filed directly would put a state nobody
agreed to in front of the host's executor without ever passing the guarded transition that checks
who agreed.RecordExecutionAsyncrefuses a row carrying no attestation: an execution report is
evidence that an approved write ran, and a row nobody attested was never approved. Both throw
ArgumentException. A host that seeded fixtures by filing approved rows files them pending and
transitions them, which is what its production path already does. DocketEntry.Requirement— the review level the policy chain resolved, written when the row
is filed and persisted with it (ReviewerConfirmationby default, so existing constructions
compile). It was previously nowhere on the record, and a reader had to infer the requirement
from what happened afterwards — which cannot distinguish a row that required one reviewer from a
row that required two and got one. A host with a customIDocketStorepersists and returns it;
the EF stores add the column through the same migration and drift heal as the other row facts.CountPendingAsync(ct)— how many entries are awaiting review, as a number. The docket-depth
gauge asked for the rows instead, every fifteen seconds, unpaged and across every tenant. A host
with a customIDocketStoreimplements it as aCOUNTover the pending rows that a read would
report pending.ListAllPendingAsynckeeps its deprecation and has no caller left insrc/.
Migrations
One migration per provider, both idempotent and both safe to run against a beta.1 database.
- PostgreSQL —
20260904040752_AddDocketRowFactsadds the sixteen newaffiant."Docket"
columns, four indexes, and backfillsCreatedAtTicks/ExpiresAtTicksfrom the instants they
mirror. Applied byMigrateAffiantSchemaAsync(ordotnet ef database update). - SQLite —
AffiantMigrator's drift heal adds any of those columns the existingDockettable
lacks, creates the indexes, and backfills the tick columns row by row. SQLite has no
migration history here (the checked-in migrations were generated under the Npgsql provider and map
columns Npgsql's way); the heal is the mechanism, and it runs on every
MigrateAffiantSchemaAsync. - The tick columns exist because SQLite's EF provider can translate neither an inequality nor an
ORDER BYover aDateTimeOffsetinto SQL, so a paged listing or a bounded sweep would otherwise
have to load every candidate row and filter in memory. Both backends now read the integer, and the
backfill is what stops a pre-existing row from reading as filed and due at the beginning of
time.
The wire as the rulebook defines it
A record a person swears to has to mean the same thing on both sides of a network, and years later.
This change gives the framework a canonical form to hash, one spelling for every value, and an
envelope that says which protocol it speaks — the four serialization rules and the tool-result
discriminator, together, because they are one subject seen from four angles.
Added
-
Affiant.Core.Serialization.CanonicalSerializer— the canonical form of an Affidavit and its
accepted amendments, and the SHA-256 over it.Canonicalizereturns UTF-8 bytes,CanonicalString
the same document one encoding step earlier,CanonicalHash64 lowercase hexadecimal characters.
Object keys are sorted by Unicode code point at every level; there is no insignificant
whitespace; numbers are the shortest decimal that round-trips, written positionally (1e21in
full, never1e+21), with-0written0and a non-finite number refused; strings escape only
what JSON requires;nullis written and an absent property omitted; money is its two strings.
The overloads that take an amendment map fold it throughAffidavitAmendments.Apply, so the bytes
a decision produces and the amended record a Docket row keeps cannot disagree about that decision.
The form is taken over the accepted state — the amended record where there is one, the proposal
otherwise. A form over the proposal alone would let a host's execution grant, minted for the record
a reviewer was shown, still validate the record they amended.
All seven of the protocol's normative byte vectors reproduce, byte for byte and digest for digest,
at the rulebook'sv0.1.2tag,
where all seven describe the v0.1 record. The amended vector also states the accepted state it
canonicalises, andApplyAmendmentsForCanonicalreproduces that state property for property, not
only the bytes over it.One correction fell out of the re-vendoring. The tag an accepted amendment mints carried the
amended field's conversation turn, not the Affidavit's. Those were the same number for as
long as no vector stated a turn on the record, and different the moment one did: a reviewer's
correction belongs to the conversation the proposal was made in, and the displaced tag's own turn
says when the machine produced the value it replaced. Dating a person's act to the machine's turn
is the wrong answer whether or not a vector catches it, and the canonical path
(ApplyAmendmentsForCanonical) now reads the turn off the record, as the rule states it.
The typed path (AffidavitAmendments.Apply) still mints the tag with the amended field's own
turn, because theAffidavitrecord has nowhere to state one: noconversationTurn, no protocol
version and no created-at instant. That gap is the one the parity manifest's canonical rows name,
and closing it makes the two paths agree by construction rather than by inspection. -
Money(Amountdecimal string,CurrencyISO 4217 shape) and its converter, which writes the
two strings and refuses a JSON number where money was expected, naming the rule and saying why:
no binary float represents0.10, so a card showing "£4,000.10" and a store holding
4000.099999999999disagree about what was approved with nothing on the record to say which the
reviewer saw.Money.Parse/TryParse/IsMoneyread one out of anAffidavitField's
object?value, andScaleFits(minorUnits)checks a scale the host declares. No currency list is
embedded — ISO 4217 changes, and a table frozen into a serialization type would be wrong within a
year; the shape is checked here and membership is the host's check. -
AffiantProtocol.Version— the protocol version string this build speaks, written once.
EvidenceCardRequest, the three Docket notifications andDecisionResulteach carry it. -
Affiant.Abstractions.Serialization.AffiantJson— the JSON conventions every envelope is
written under, in one place: camelCase names, enums as strings in the exact casing each schema
freezes, nulls written, one spelling for every instant, money as strings.AffiantJson.Configure
applies them to an options object of your own; the SignalR hub protocol now calls it instead of
restating them, andToolEnvelopeExtensions.ToJsonStringuses it. Three code paths each declared
their own spelling of the same record before — which is how an enum inside a tool result crossed as
an integer while the same enum inside an Evidence Card crossed as a string. -
EvidenceCardRequestgains seven properties and a factory,EvidenceCardRequest.For(...), that
fills them from the record rather than from a caller:PopulatedConfidenceandEmptyFieldCount
(a card shows all three confidence numbers, and this is where the seed put the two companions);
RequiresConfirmation(the policy chain's verdict, not a property of the evidence, which is why it
belongs on the envelope);Blocked(why no decision will be accepted — see below);Presentation
(the per-field rendering hints the host's strategy declared, lifted onto the envelope);
Warnings; andHostOperation(the host's own verb — "Reprice", "Onboard" — carried beside the
protocol's two-valued shape vocabulary, never instead of it, so a card can be headed with the term
a person recognises while a policy still tests the shape). The last three are omitted when
empty rather than written null; the others are written null, because a required-and-nullable
property a reader can rely on finding is worth more than three saved bytes. -
FieldPresentation— one field's rendering hints (Name,Kind,AllowedValues,Pattern),
sworn to by nobody: the gate carries a hint and validates nothing against it, and none of it is
part of the canonical form. -
BlockedMarker(RequirementNotImplemented,CoverageRefused) — why an entry cannot be
decided even though it sits inpending. Declared now so the card envelope can carry it typed;
it is null on every card until the Docket row gains its own blocked column, which is a separate
change. -
DocketTransitionNotificationandExecutionOutcome(unexecuted/executed/
failed) — the state-change notification and the execution axis of an approved row. Declared so
the wire has one spelling of them; not yet emitted by any framework path. -
DecisionResultandDecisionOutcome(approved/rejected/expired/
resubmitted) — what became of a review, as a report and never as an authorization: the Docket row
is the sole record of approval authority and nothing replayed from this envelope stands in for it.
DecisionResult.For(outcome)maps the gate's own outcome union onto the protocol's vocabulary and
refuses a referral rather than reporting it as one of the four. -
AffidavitAmendments.AmendmentTag(...)— the one definition of what a reviewer's correction is,
as provenance, extracted so the amendment path and the canonical serializer mint the same tag. -
ProvenanceTag.At(DateTimeOffset?) — when the tag was minted. Null at every framework mint
site except a reviewer's accepted amendment, whose instant is passed in rather than read from a
clock; the rest stamp it once the injected clock lands.
Changed
- The tool-result discriminator is
kind, not$type. It was inherited from Semantic Kernel's
KernelContentpattern rather than chosen, and$-prefixed names are reserved by JSON Schema — a
discriminator a schema cannot name is a discriminator nothing can validate. - A provenance tag's
Evidenceis spellednoteon the wire. The CLR name is unchanged. - An instant is written UTC with milliseconds and a trailing
Z—2026-08-01T00:05:00.000Z—
where .NET's round-trip default wrote2026-08-01T00:05:00+00:00. The same instant; one spelling,
because a canonical form is a byte sequence and two spellings of one instant are two hashes of one
record. - A
ReviewStatuscrosses lowercase —"pending"— which is the spelling the v0.1 schemas freeze
and the one the demo hosts' own status queries already return. AProvenanceSourcestays
PascalCase and aReviewRequirementstays PascalCase, for the same reason: the schemas freeze each
set as it stands and no implementation case-folds one on the wire. EvidenceCardRequestFactory.CreateAsynctakes an optionalhostOperationand builds the card
throughEvidenceCardRequest.For, so the filing path, the reconnect rebroadcast and the expiry
sweep cannot produce three different cards for one entry.
Upgrade notes (breaking changes; pre-1.0 breaks are permitted and declared)
- A tool result's discriminator is now
kind. Client migration: a TypeScript client that
doesswitch (result.$type)readsundefinedafter the upgrade and falls through to its default
arm — silently, since nothing throws. Change it toswitch (result.kind). The three values —
"read","write","error"— are unchanged. A .NET consumer deserializing through
ToolEnvelopeneeds no change beyond re-serializing any payload it had stored as text. - A provenance tag's
evidenceis nownoteon the wire. Client migration: a client
renderingtag.evidencerenders nothing; readtag.note. A stored payload written before this
release deserializes withEvidencenull. - A tag carries
atandbinding, and an Affidavit carriespopulatedConfidenceand
emptyFieldCount. Additive: a client that ignores unknown properties needs no change. A client
that rejects them does. - An instant is spelled
...T00:05:00.000Z. Client migration: none for anything that parses
the string —new Date(s)handles both forms. A client or test asserting the exact former string
needs updating. Sub-millisecond precision is not carried. - A
ReviewStatuson the wire is lowercase. Client migration: a client comparing against
"Pending"compares against"pending". EvidenceCardRequestgains seven optional constructor parameters afterPriorAmendments.
Existing construction compiles unchanged and produces a card with the companions unset; a
positional deconstruction of four elements does not compile. Migration: build cards with
EvidenceCardRequest.For(...), which fills every repeated number from the record it is given —
passing them by hand is how a card ends up reporting a confidence that is about a different set
of values than the ones it shows.ProvenanceTaggains a sixth positional parameter (At, defaultednull). Existing
construction sites compile unchanged; a positional deconstruction of five elements does not.
One decision core, and the record a decision leaves
Until this change a decision reached a blocking FileReviewAsync before the framework compared
the tenant, before it asked the host's authorization port and before it looked at the row's blocked
marker. While a filing was awaiting, a member of another tenant approved the row and was written onto
it as the attestor; a principal the host's policy declined did the same; and so did a host that had
registered no policy at all, which the deny-all default exists to refuse. Separately, a host that
delivered its own EvidenceCardResponse unblocked the waiter and the row was written approved with
no attestation, after which an execution report was accepted against it.
Changed — breaking
- Every decision runs one core, and nothing is handed off before it. The principal; the
tenant-scoped row, where a row in another tenant isentry-not-found; the host's authorization
port; the state and blocked checks; the attestation. Only then, and only if the row actually
transitioned, is a waiting call unblocked — by the result, which it reports and does not act on. IStreamingTransport.AwaitEvidenceCardResponseAsyncreturnsDecisionHandOff, and
TryDeliverResponsetakes one, in place ofEvidenceCardResponse. Migration: a host hub
takes a reviewer's decision toReviewGate.HandleDecisionAsyncwith aDecisionContextand never
touches the transport. A custom transport changes two signatures and keeps its waiter registry as
it is. Only the gate can construct a hand-off — the constructor is internal — so a delivery can no
longer approve anything.ReviewGate.FileReviewAsyncis[Obsolete](AFFIANT0002), kept for one release. It decides
nothing: it awaits a hand-off and reports it, and what it still owns is the timeout. Migration:
file withFileForReviewAsyncand decide withHandleDecisionAsync.Attestor.Member.FromStorage,Attestor.MemberViaRelay.FromStorageand
Attestor.StandingOrder.FromStorageare internal. Rehydration is the stores' business, and a
factory that mints a member attestation from a bare string is one a machine caller can reach
(AZ-3). Migration: a host that was reconstructing an attestation reads it off the row instead;
Attestor.Member.Of(Principal.Member)is the only public way to a member attestation, and it takes
a person.IDocketStore.MarkBlockedAsynctakes aDocketScope. Migration: pass the tenant the entry
belongs to. Without it, any caller holding an entry id could write a blocked marker onto another
tenant's pending row — and because the guard that stops a marker being overwritten also stops it
being cleared, the row became permanently undecidable.IDocketStore.UpdateAmendmentsAsyncis removed. It was an unscoped, unguarded,
last-write-wins overwrite of any row's amendments by entry id alone, and the remarks defending its
missing status guard named a caller that no longer exists. Migration: an approval's accepted
amendments are written by the guarded transition; a refused late decision's are preserved by
PreserveAmendmentsAsync. A recorded fact is not edited in place (DK-4).DocketEntrygainsChanneland the three stores persist it (one added column, folded into
the same migration that added the other row facts). Additive for a caller; a schema change for a
deployment, applied byMigrateAffiantSchemaAsync.ApprovalVerdictgainsRiskScoreandIApprovalPolicygainsConfigurationFault, both
defaulted. A Standing Order that declares a risk ceiling with no scorer registered is now refused
at startup (CV-1) rather than on its first evaluation. Migration: register a calculator with
SetRiskScoreCalculator<T>(), or drop theRiskThresholdoverride.- A blocked entry answers
decision-not-pendingwith the marker's own code in the refusal's
detail, where it used to answer with the marker's code as the refusal code (AZ-4). Migration: a
host branching onrequirement-not-implementedorcoverage-refusedas an error code branches on
decision-not-pendingand reads the detail.
Fixed
- The stores refuse to write an unattested decision. A transition to approved or rejected without
an attestation is anArgumentExceptionon all three backends; so is one attested to a person with
no decision record. A Standing Order approval is the one that carries no decision record, because
nobody chose anything. Defence in depth: the decision core makes the state unreachable, the store
makes it unwritable. MarkExecutedAsyncrefuses a row that carries no attestation (AZ-5). An executor is reachable
only through an entry that says who approved it.- Every refusal names its reason. A second execution report says a host reports once; a
not-pending refusal says which state the row is in; a not-found refusal says nothing about the row,
which is the point. - Filing is scoped and its ids are derived (GT-2, GT-4). The idempotent-replay lookup compared no
tenant, so a caller supplying another tenant's entry id received that tenant's Affidavit on its own
session group. With noReviewContext.EntryIdthe gate derives one — see
EntryIdDerivationbelow for the material, which is the protocol's. - A sweep tick is bounded across all three of its phases (DK-3), and each phase resumes from where
it stopped rather than re-walking the same first rows. - A rehydration page fills across the group boundary (DK-5), instead of stopping at it and
reportingmorewith a limit it had not spent. - A registry event is observable without an ambient span (TL-1), so the decision and
execution-report paths — reached by host code directly — no longer emit into nothing. - A model's tool argument is a value it proposes, not evidence (PV-1). It was tagged
Conversationat 0.9 — the grade the ladder reserves for a value read out of the member's own
turn — so the model's guess was presented as though the member had said it and, the merge being
confidence-first, displaced a value the inference port reported as literally present in the turn at
anything under 0.9. The capture now records the argument as the value the model proposes and mints
no tag for it. What a host must know: what swears for a field is a deterministic interceptor or
the host's inference port, and where neither speaks the field is swornEmptyat confidence 0 —
so an application that registers neither, and relies on the model's arguments alone, now files a
record that swears to nothing and the substance rule refuses it (GT-3). That is the rule working:
a proposal nothing vouches for should never have reached a reviewer looking like one that
something did. - A bound tag wins a tie (PV-2, PV-3): at equal confidence and equal grade, a tag pointing at
something an auditor can re-check displaces one pointing at nothing. - An inference reports whether the value was literally in the turn, and which span it read, so a
value read verbatim is gradedConversationand carries an utterance-span binding. - A Standing Order approval broadcasts its Evidence Card, with
requiresConfirmationfalse
(SR-4), and a blocked row's card carries the row's own marker and says in words why no decision will
be accepted. standing-order.firedis emitted by the gate, where the write is actually approved with no
person present and where the entry id exists to name.ApprovalPolicyEvaluatormeasures a review window against the injectedTimeProvider(GT-4),
so the chain and the gate cannot disagree about whether a window is stampable.DocketDepthInstrument.StopAsyncno longer throws when a host disposes its services before
stopping them.
The version this tree builds, and the run that measures it
VersionSuffixisbeta.3. This tree is the conformance release's candidate, so every
version-derived thing says so: the packages, the driver's run log and the parity manifest's
versionfield.- The driver reads the version it measured off the packages
(AssemblyInformationalVersionAttributeonAffiant.Core, build metadata stripped) instead of
carrying a constant. The run log isconformance/results/dotnet-<version>.json, so a branch build
can never overwrite the record of a release that shipped:
conformance/results/dotnet-1.0.0-beta.1.jsonstays exactly as1.0.0-beta.1left it.
conformance/compare-parity.pyandconformance/regenerate-parity.pyask
Directory.Build.propsthe same question rather than naming a file. - The negative-oracle assertion is a statement about a named release. The rulebook records which
fixtures must fail ondotnet@1.0.0-beta.1; a release that fixes those rules is supposed to pass
them. Run against any other version the assertion reports itself skipped, with the reason, rather
than failing (every correction would be a red build) or quietly passing (the check would stop
running and nobody would know). PackageValidationBaselineVersionis1.0.0-beta.1.1, the latest published version. It names
a versiondotnet packdownloads from a feed and diffs this build against, so a break against
what is already on nuget.org fails the build unless it is declared. Moving the baseline off
1.0.0-beta.1retired three declarations inAffiant.Policies— the removal of the framework's
stock risk formula, the risk threshold's type and the risk calculator's return type were breaks
against beta.1 and are what beta.1.1 shipped, so against that baseline there is nothing left to
declare.
The conformance driver
Added
-
Affiant.Core.Services.ToolCoverage— coverage is a concept the gate holds (CV-4, CV-1).
Affiant intercepts a write by being the tool that performs it, and three kinds of write-capable
tool cannot be intercepted at all: one the model provider executes on its own side, one a hosted
MCP server performs, and one declared write-capable with no execute step for the gate to replace.
A write made through any of them reaches a system of record with no Affidavit, no reviewer and no
Docket row — and looked, from the outside, exactly like a write that had been through the gate.
Until now the only refusal anywhere in the framework was an internal audit inside the two adapter
packages, so a host on a third wiring had none at all.Two halves, because the gap is knowable at two moments.
ToolCoverage.Audit(name, writeCapable, category)refuses at wire-up, with the protocol'scoverage-refusedcode and one
coverage.refusedevent per tool: a coverage gap must not be discoverable only by the write it
silently let through.ToolCoverage.DeclareUncovered(name, category)is the other half — a host
that knows it cannot cover a tool and says so. An entry filed for a declared tool is blocked
with the category: never auto-approved whatever the policy said, no decision on it ever accepted,
and the Evidence Card carries the marker and says why in words. A Standing Order approves a write
the gate stands in front of; a declared-uncovered tool is one the gate has been told it cannot.
Register it withservices.AddSingleton<ToolCoverage>()and declare at start-up; a host that
declares nothing sees no change. Both adapters' hosted-tool audits refuse through it, so a
coverage gap raisesAffiantCoverageExceptioncarryingcoverage-refusedwhichever wiring
noticed it, and onecoverage.refusedevent per tool names one of CV-4's own three categories.
Breaking for a host catchingInvalidOperationExceptionaroundWithAffiant: catch
AffiantCoverageException(orAffiantRefusalException, its base) instead.
CV-1, re-read: what the wire-up validator enforces and what it does not
CV-1 says a wiring the gate cannot run is refused before anything is proposed. AddAffiantCore()'s
AffiantWireUpValidator runs at startup and refuses, naming the fix for each:
- no
IStreamingTransportand noIDocketStore— a review with nowhere to go and no queue
to sit in; - no
IPreviousValueSourcewhere a declared tool is update-shaped — an update Affidavit swears
to what each field replaces, and only the host's system of record knows that; - no
IReviewContextProvider, noReviewGateor noIDecisionAuthorizationPolicywhere any
declared tool is write-capable — a review loop in which no proposal can be routed, filed or
decided; - a Standing Order that declares a risk threshold with no scorer registered — the policy chain
is built in a throwaway scope and asked, so an unbacked threshold is a refusal rather than a
silent non-fire; - a coverage gap — a write-capable tool the gate cannot stand in front of, refused by
ToolCoverageat the adapter's wire-up (new in this release).
Two clauses are not enforced at startup, deliberately, and neither is a coverage gap:
- the inference port. A host that registers none is not misconfigured: a Sequence C host hands
the gate fields it has already tagged and never asks a model anything. What a host without one
gets is an Affidavit whose fields nothing swears for, and GT-3 refuses it at the proposal with
a message naming the tool — the refusal a host actually needs, at the moment it means something,
rather than a startup error for a shape that is legitimate. - the projection port.
AddAffiantCore()registers the schema-driven projection, so "missing"
is not a state a host can reach; a host that replaces it is exercising a supported seam. A
projection that produced an unlawful record is caught by the record's own rules (AF-1, AF-3) on
the first proposal, which is where the fact is knowable.
The third way a write could once pass unreviewed — a registered IReviewContextProvider that
returns no context for one particular call — only a live request can know, and ReviewGateFilter
refuses it there.
-
Affiant.Testing.ComplianceHarness.ConformanceSuite— the conformance driver ships. The
runner — loading, step execution, observation, matching and reporting — lives in the harness
package, so a host's own compliance tests run the rulebook's suite through the same code the
framework runs it through, and get the report the framework's release notes are derived from
rather than a re-implementation living beside somebody's test.Run(protocolRoot, writeRunTo)
returns every fixture's outcome, the failing ids and the run document
results.schema.jsondescribes. The rulebook stays vendored by the caller: a suite a run measures
against has to be a document a reader can check, pinned in a repository rather than fetched at run
time. The root the caller names is the whole of it — fixtures, both schemas and the telemetry
registry come from there and nowhere else, with no copy beside the assembly and no fallback, and a
root missing any of them throws before a single fixture runs, naming the file and what it is for.
CI'sharness-consumerjob proves it the way an adopter meets it: pack the ten packages to a local
feed, restore a project whose only Affiant reference is that package, and run the suite from a
rulebook directory that is deliberately not beside the assembly. The package gains references toAffiant.Docket,Affiant.PoliciesandJsonSchema.Net—
a compliance run files a proposal through the shipped gate against the shipped store and the
shipped policy chain, and validates every document against the rulebook's own schema first. -
tests/Affiant.Conformance.Tests— the framework's invocation of it. Runs the
Sakwala/affiant-protocolrulebook's promoted
fixture suite (56 declarative fixtures and 7 canonical byte vectors) against the packages this
repository builds, and publishes what it finds. The suite is vendored from the ref
conformance/PROTOCOL_PINnames and verified against checksums, so the driver builds offline and
an edited fixture cannot pass unnoticed (conformance/sync.sh). The run emits a machine-readable
log atconformance/results/dotnet-<version>.json, named for the version the built
Affiant.Coreassembly reports, and CI asserts that the set of fixtures that fail is exactly
the set the parity manifest declares — in either direction, so a gap that closes has to be
published rather than quietly disappearing.Each of the eight step kinds is bound to a shipped entry point (
tests/Affiant.Conformance.Tests
README.mdnames the binding for each), andwrap-executeruns the tool-wrapping pipeline
itself — the argument capture filter, the inference step, the schema-driven projection — rather
than a restatement of what they do, so a fixture cannot be passed by a driver that supplies its
own answer. Two invariants are checked on every fixture whether or not it asks for them: an
attestation names the entry it attests to, and a filing's Evidence Card agrees with the row it
was broadcast for.Reading against the rulebook's
v0.1.2pin on this candidate: all 63 pass. The parity manifest
atconformance/parity/dotnet-v0.1.jsontherefore declares an empty failing list, which is what
this release's acceptance asks for. Runningconformance/regenerate-parity.pyagainst the run log
committed beside it reproduces the committed manifest byte for byte — the two files are one claim
and its evidence, and the run log names the git commit of the tree it measured, so a reader can
check both against a checkout. A log committed inside the tree it measures can only name that
tree's commit, so the commit that carries the log is its child, identical except for the log
and the manifest derived from it.The pin moved from
v0.1.1tov0.1.2in this change. That release states in SR-1 that the
canonical form is taken over the Affidavit as the schema defines it — protocol version included —
regenerates the two conformance fixtures whose pinned content hashes had been produced by a
reference runtime whose model omitted it, and states the entry-id derivation in GT-4. This
implementation already produced the canonical form v0.1.2 states and the hashes it regenerated; it
adopted the entry-id derivation in this same release, including on the resubmission path,
where it had been minting a random id. The re-pin is what let the last two rows close.conformance/results/ORACLE-RUN-1.0.0-beta.1.mdreads the shipped release's own run against the
rulebook's negative oracle, andconformance/results/dotnet-1.0.0-beta.1.jsonis that release's
record, kept as it was published.