feat(sdk): derive assurance in TypeScript and Go, not only Python
Python derived the assurance ladder and the other two clients did not, so a TypeScript verifier -- which is what the product UI is -- had no way to present it without inventing one. The rule that L3 is derived and never signed only holds if every client applies it, so this is the property rather than tidiness. All three now report four facts kept apart: what the vault signed, whether a quorum was met, whether an anchor confirmed, and what a verifier may therefore report. A single badge would hide which of them was observed, and that matters most exactly when one is missing. Each carries the two asymmetries in its own tests. A witness outage withholds L3 without reducing what the vault signed, because event-time assurance is a fact about the past that no later outage changes. And an anchor never promotes anything -- the report says so out loud, so a reader does not infer it did. The nine-case table is enumerated in each language, which is the only way two implementations of a rule this narrow can be shown to agree. Python and TypeScript were checked against each other directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -934,3 +934,90 @@ func toString(value any) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VaultAssuranceLevels are the levels a vault may sign. L3 is deliberately
|
||||
// absent: a vault that signed it would be asserting a property it cannot
|
||||
// observe.
|
||||
var VaultAssuranceLevels = []string{"L0", "L1", "L2"}
|
||||
|
||||
// DerivedAssuranceLevel is computed by a verifier and never signed.
|
||||
const DerivedAssuranceLevel = "L3"
|
||||
|
||||
// AssuranceReport carries four facts, deliberately not collapsed into one
|
||||
// badge. ADR-0010 requires a verifier to present vault assurance, witness
|
||||
// state, quorum and anchor state separately: a single level would hide which of
|
||||
// them was actually observed, and the difference matters most exactly when one
|
||||
// is missing — an outage withholds L3 without changing what the vault signed at
|
||||
// the time.
|
||||
type AssuranceReport struct {
|
||||
Effective string `json:"effective"`
|
||||
VaultAssurance string `json:"vault_assurance"`
|
||||
WitnessQuorumMet *bool `json:"witness_quorum_met"`
|
||||
AnchorConfirmed *bool `json:"anchor_confirmed"`
|
||||
Derived bool `json:"derived"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
// EffectiveAssurance derives the assurance a verifier may report, per
|
||||
// ADR-0010 §40.4. The rule is narrow —
|
||||
//
|
||||
// if vaultAssurance == "L2" and witnessQuorumMet: L3, else vaultAssurance
|
||||
//
|
||||
// — and the narrowness is the point. Anchor confirmation is reported alongside
|
||||
// and never promotes anything: an anchored L1 is an anchored L1.
|
||||
//
|
||||
// A witness outage withholds L3; it does not reduce what the vault signed. That
|
||||
// asymmetry is deliberate, because event-time assurance is a fact about the past
|
||||
// and no later outage can change it.
|
||||
//
|
||||
// nil for either observation means "not evaluated", which is reported as such
|
||||
// rather than treated as false: a verifier that did not look and one that looked
|
||||
// and found nothing are not the same verifier.
|
||||
func EffectiveAssurance(vaultAssurance string, witnessQuorumMet, anchorConfirmed *bool) (*AssuranceReport, error) {
|
||||
if vaultAssurance == DerivedAssuranceLevel {
|
||||
return nil, fmt.Errorf("L3 is verifier-derived and can never be a signed vault assurance")
|
||||
}
|
||||
known := false
|
||||
for _, level := range VaultAssuranceLevels {
|
||||
if level == vaultAssurance {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return nil, fmt.Errorf("unknown vault assurance: %q", vaultAssurance)
|
||||
}
|
||||
|
||||
reasons := []string{}
|
||||
switch {
|
||||
case witnessQuorumMet == nil:
|
||||
reasons = append(reasons, "witness quorum not evaluated")
|
||||
case !*witnessQuorumMet:
|
||||
reasons = append(reasons, "witness quorum not met")
|
||||
}
|
||||
switch {
|
||||
case anchorConfirmed == nil:
|
||||
reasons = append(reasons, "anchor state not evaluated")
|
||||
case *anchorConfirmed:
|
||||
// Said explicitly so a reader does not infer that an anchor lifted the
|
||||
// level. It never does.
|
||||
reasons = append(reasons, "anchor confirmed; anchoring does not promote assurance")
|
||||
}
|
||||
|
||||
effective := vaultAssurance
|
||||
if vaultAssurance == "L2" && witnessQuorumMet != nil && *witnessQuorumMet {
|
||||
effective = DerivedAssuranceLevel
|
||||
reasons = append(reasons, "L3 derived from L2 plus a met witness quorum")
|
||||
} else if vaultAssurance == "L2" {
|
||||
reasons = append(reasons, "L3 withheld: the quorum was not met or not evaluated")
|
||||
}
|
||||
|
||||
return &AssuranceReport{
|
||||
Effective: effective,
|
||||
VaultAssurance: vaultAssurance,
|
||||
WitnessQuorumMet: witnessQuorumMet,
|
||||
AnchorConfirmed: anchorConfirmed,
|
||||
Derived: effective == DerivedAssuranceLevel,
|
||||
Reasons: reasons,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user