@vorionsys/basis-scorer
v0.1.1
Published
BASIS reference scorer — a pure, deterministic, fail-closed function that RE-COMPUTES an agent's trust score and tier from a validated proof-event chain, so a client's claimed tier can only be lowered, never raised. Reuses @vorionsys/basis-spec canonical
Readme
@vorionsys/basis-scorer
The BASIS reference scorer — a pure, deterministic, fail-closed function that re-computes an agent's trust score and tier from a validated proof-event chain (RFC-0002), so a client's claimed tier can only be lowered, never raised.
"Invert the artifact, not the trust." A claimed tier and any asserted
trust_deltaare treated as an upper bound and a cross-check — never as the source of truth. The scorer re-derives gain and loss from execution outcomes through the canonical@vorionsys/basis-specformulas, which is what makeseffectiveTier = min(claimed, recomputed, caps)real.
This package reuses the canonical constants and formulas from
@vorionsys/basis-spec (calculateGain, calculateLoss, tierFromScore,
RISK_LEVELS, OBSERVATION_TIERS, CIRCUIT_BREAKER, RISK_ACCUMULATOR, …).
It does not copy any constants.
What it does
import { scoreChain } from '@vorionsys/basis-scorer';
const result = scoreChain(events /* ProofEvent[] */, {
observationTier: 'GRAY_BOX',
riskByActionType: { 'db.read': 'LOW', 'db.write': 'MEDIUM' },
claimedTier: 'T5', // the client's assertion — an upper bound only
});
// result.recomputedScore — independently re-derived score, [0,1000]
// result.recomputedTier — tierFromScore(recomputedScore)
// result.effectiveTier — min(recomputed, observation cap, policy cap, claimed)
// result.overClaim — advisory: a claim exceeded the recomputed value
// result.divergences — advisory evidence of claimed-vs-recomputed gaps
// result.policyHash — SHA-256 of the (untrusted) policy usedscoreChain is a pure function over an already structurally +
signature/linkage-validated chain (validation is upstream, per the brief).
It still fails closed on anything it cannot compute.
How a score is built
- Start at
INITIAL_TRUST_SCORE(0). - Sort events by their own
occurredAtinstant (then a causal-precedence tiebreak, theneventId). No wall-clock is ever read. - Replay in order:
execution_completed(status:'success')→+calculateGain.execution_failed→+calculateLoss(negative) and aP(T)·Rcontribution to the rolling 24h risk accumulator.execution_completed(status:'partial')→ zero gain (fail closed; a partial success is not evidence of full competence).trust_delta/decision_made.trustScore→ cross-check only, never summed.
- The risk multiplier for each outcome is resolved by walking
executionId → execution_started.decisionId → decision_made.intentId → intent_received. RFC-0002.1: if thatintent_receivedevent carries a SIGNED, in-chainriskLevel, it is preferred (riskSource: 'chain'). Otherwise the scorer falls back to mappingactionTypethrough the policyriskByActionType(riskSource: 'policy', a deprecated out-of-chain path), and fails closed to max risk when neither resolves (riskSource: 'failclosed'). See Limitations. - The result is capped:
effectiveTier = min(tierFromScore(score), observation-tier maxTier, policy ceiling, claimed tier), with a T0 floor when the circuit breaker is tripped.
Determinism guarantee
The same chain + the same policy produce a byte-identical ScoreResult on
any platform.
- All time is derived from
occurredAt— neverDate.now. Timestamps are parsed by a pinned RFC-3339 parser to exact BigInt nanoseconds (offset normalised to UTC), so…00.123456Zand…00.123Zstay distinct and…Zand…+05:30for the same instant sort identically. Anything not losslessly representable (bare local time, sub-nanosecond precision, garbage) is rejected → fail closed. - The running score is held as a scaled integer (BigInt) at a pinned
precision (default
1e6); eachcalculateGain/calculateLossresult is quantized with round-half-even before it is added, and the value fed back into the nextMath.log/Math.cbrtis the quantized score — so the float input to libm is identical everywhere and the round-half-even on the output absorbs any last-ULPlog/cbrtdivergence. - The fold is strictly sequential (no reassociation / parallel reduce).
- A true
(instant, eventId)tie is rejected (non-deterministic ordering).
Residual risk (honest):
Math.log/Math.cbrtare not guaranteed bit-identical across libm implementations. The round-half-even quantization at1e6is designed to absorb the last ULP, and a long-chain stability test guards it, but a sufficiently exotic libm could in principle escape the quantization. Tightenprecisionand run the cross-platform check on the target platform if this matters to you.
Fail-closed guarantee
Any malformed, unverifiable, unknown, or non-deterministic input degrades
toward least privilege (T0 / FAIL_CLOSED), never toward the higher value:
- Unknown / unmapped
actionType, or a broken risk link →defaultRisk(defaultLIFE_CRITICAL, multiplier 30): cheap gain impossible, failure maximally penalised. - Unknown observation tier → most restrictive (
BLACK_BOX: ceiling 600 / maxTier T3) + flag. ATTESTED_BOX/VERIFIED_BOXwithoutassertVerifiedObservation→ downgraded toWHITE_BOX(TEE verifiers are stubs ecosystem-wide; we never assume a higher observation tier on trust).- Unparseable
occurredAt, missingeventId, invalidprecision, malformedclaimedTier/ceilingTier, or a(instant, eventId)tie →FAIL_CLOSEDwitherrorset. - Any residual throw (e.g. a corrupted ceiling driving NaN/Infinity, or
tierFromScoreout of range) is caught →FAIL_CLOSEDT0. - A missing
claimedTierimposes no client upper bound (it is not defaulted to T7); the observation/policy caps still apply. A claim can only ever lower the result.
What overClaim / divergences actually mean (no over-statement)
The divergence cross-check compares the runtime's claimed numbers
(trust_delta.newScore, decision_made.trustScore) to the scorer's running
recomputed value and flags gaps. This is advisory evidence, not enforcement.
A sophisticated runtime controls its own claimed numbers and can make
claimed == recomputed to evade the flag while still asserting a high
claimedTier at the API boundary.
The actual defence against over-claim is effectiveTier = min(...) — the
client's claim is an upper bound the recomputation can only lower. Do not read
overClaim as "the scorer caught the lie"; read it as "the claimed and
recomputed values diverged here."
recomputedScore is a quantized fixed-precision decimal in [0, 1000] (e.g.
200.265), not an integer.
v0.1 scope
In scope (implemented faithfully):
- Pure deterministic fold over a validated
ProofEvent[]→{ recomputedScore, recomputedTier, observationCappedTier, effectiveTier, status, circuitBreaker, riskAccumulator24h, divergences, overClaim, flags, scoredEventCount, policyHash }. - Gain on
execution_completed(success)and loss onexecution_failedvia the canonicalcalculateGain/calculateLoss(no copied constants). - Risk resolution by linkage walk + explicit policy map, fail-closed to max.
- Observation ceiling: drives gain headroom; caps
effectiveTier; honestWHITE_BOXcap for stubbed TEE. - Circuit breaker (tripped / degraded) with a has-qualified latch (see
below) and the rolling 24h risk accumulator over
occurredAt. - Recompute-vs-claimed cross-check (advisory) and
effectiveTier = min(...). shadowModeexclusion (shadow/testnetexcluded from production accounting;production/verified/ absent move the score).- Golden vectors + determinism + fail-closed + recompute tests.
Circuit-breaker semantics (the fresh-agent latch)
CIRCUIT_BREAKER.trippedThreshold (100) and degradedThreshold (200) sit
above INITIAL_TRUST_SCORE (0). A naive "score < 100 ⇒ tripped" rule would
brick every fresh agent at birth and freeze the very gains it needs to climb
past QUALIFICATION_PASS_SCORE (200). That is a logic bug, not a safe failure.
This scorer therefore distinguishes climbing from falling: the
score-path circuit breaker engages only after the recomputed score has
crossed QUALIFICATION_PASS_SCORE (200) at least once during the replay (a
hasQualified latch derived purely from the events). A fresh agent climbing
from 0 is treated as provisioning, not as a tripped agent. The risk
accumulator path can still trip/degrade regardless of qualification (a burst
of failures is always a safety signal that can only lower trust).
Deferred to v0.2 (explicitly, not silently omitted)
PROMOTION_DELAYS(T5–T7 time-gating). Day-granularity promotion holds need a "sustained behaviour" window the events do not cleanly express (no promotion-eligibility marker). v0.1 computestierFromScorebut does not enforce high-tier promotion delays.DORMANCYhalf-life / idle deductions. Requires a defined "now" to measure idleness; deriving it from the last event vs an evaluation instant reintroduces wall-clock non-determinism. Out of scope; the scorer scores only the evidence present.HYSTERESISdemotion buffers. A stateful cross-evaluation tier-transition smoother, not a pure function of one chain. v0.1 returns the raw, non-stickytierFromScore.- Numeric
incident_detected/rollback_initiatedweighting. No canonical formula maps incident severity or rollback to a score delta; inventing one would over-claim. v0.1 treats them as safety / accumulator flags that can only hold-or-lower. partialfractional credit. Treated as zero gain in v0.1; any partial-credit curve needs a canonical definition first.- Cryptographic chain / signature verification. Out of scope by design — the scorer is a pure function over an already-validated chain. It still fails closed on anything it cannot compute.
Limitations (read before relying on this)
- Risk can now be SIGNED, in-chain evidence (RFC-0002.1) — and is preferred.
When an action's
intent_receivedevent carries a signedriskLevel, the scorer uses it and does not consult the caller policy for that action's risk (riskSource: 'chain'). This closes the gap this scorer originally surfaced: risk is no longer policy-controlled for any action whose chain carries it, so thepolicyHashlimitation is reduced —policyHashstill attributes the observation tier and the fallback risk map, but it no longer governs the risk multiplier (hence gain/loss magnitude) of in-chain-risk actions.- Fallback (deprecated, out-of-chain): for RFC-0002.0 chains that carry no
riskLevel, risk is still resolved through the caller-suppliedpolicy.riskByActionType(riskSource: 'policy'). On this path the same party that asserts the tier controls the risk multiplier and the observation cap (assertVerifiedObservation), so risk integrity depends on policy integrity. The exact policy is hashed intoresult.policyHashfor reproducibility/attribution. Emitintent_received.riskLevelto move off this path. - Fail-closed: if neither a signed
riskLevelnor a policy mapping resolves (or the linkage is broken), risk pins todefaultRisk(LIFE_CRITICAL,riskSource: 'failclosed'). result.riskResolutionsrecords, per scored outcome,{ eventId, risk, source }so a consumer can audit where every action's risk came from.
- Fallback (deprecated, out-of-chain): for RFC-0002.0 chains that carry no
execution_startedmust precede an outcome for risk to resolve. If a runtime omits it, theexecutionId → decisionId → intentId → actionTypelink is unrecoverable and risk fails closed to max (and the outcome is flaggedbroken_link_risk_maxed).- The divergence flag is advisory, not enforcement (see above).
No "trustless", "production-ready", or "full BASIS scoring" claims are made. This is the core, implemented faithfully, with the temporal mechanics above explicitly deferred.
API
type RiskKey =
| 'READ' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'LIFE_CRITICAL';
type RiskSource = 'chain' | 'policy' | 'failclosed'; // RFC-0002.1 provenance
interface ScoringPolicy {
observationTier: ObservationTier; // governs ceiling + maxTier
riskByActionType: Readonly<Record<string, RiskKey>>; // FALLBACK only (RFC-0002.1)
defaultRisk?: RiskKey; // default 'LIFE_CRITICAL'
ceilingTier?: TrustTier; // optional hard policy cap
assertVerifiedObservation?: boolean; // default false; gates ATTESTED/VERIFIED
precision?: number; // pinned quantization scale, default 1e6
claimedTier?: TrustTier; // client's claim — upper bound only
}
interface Divergence {
eventId: string;
kind: 'trust_delta' | 'decision_score';
claimed: number;
recomputed: number;
}
interface RiskResolutionRecord { // RFC-0002.1 — per-outcome risk attribution
eventId: string; // the execution-outcome event
risk: RiskKey; // the resolved canonical risk
source: RiskSource; // 'chain' | 'policy' | 'failclosed'
}
interface ScoreResult {
recomputedScore: number; // quantized fixed-precision decimal in [0,1000]
recomputedTier: TrustTier;
observationCappedTier: TrustTier;
effectiveTier: TrustTier; // min(recomputed, obs cap, policy cap, claimed)
status: 'OK' | 'DEGRADED' | 'TRIPPED' | 'FAIL_CLOSED';
circuitBreaker: 'NONE' | 'DEGRADED' | 'TRIPPED';
riskAccumulator24h: number;
divergences: ReadonlyArray<Divergence>;
riskResolutions: ReadonlyArray<RiskResolutionRecord>; // RFC-0002.1 attribution
overClaim: boolean; // advisory only — see note above
flags: ReadonlyArray<string>;
scoredEventCount: number;
policyHash: string; // SHA-256 of the canonical-JSON policy used
error?: string; // set when status === 'FAIL_CLOSED'
}
/** Pure, deterministic. Input chain MUST be already validated upstream. */
function scoreChain(events: ReadonlyArray<ProofEvent>, policy: ScoringPolicy): ScoreResult;
/** Convenience: recompute then min against a claimed tier. Never raises. */
function effectiveTier(result: ScoreResult, claimed?: TrustTier): TrustTier;License
Apache-2.0. Copyright 2024-2026 Vorion LLC.
