EconomyReputation
Reputation Scoring
A deterministic, tenant-local score over receipts, capability lineage, and budget usage, with no central registry.
chio-reputation is a pure function. It takes a corpus a caller assembled from local stores and a config, and it returns a scorecard. There is no kernel dependency, no network call, and no registry to consult, so the same corpus and the same config always produce the same numbers. The crate stops at the scorecard: translating a composite into an action is the caller's job, and the capability authority's issuance policy, documented below, is the shipped example of one.
Inputs
A scorecard is computed against a LocalReputationCorpus the caller assembles:
pub struct LocalReputationCorpus {
pub receipts: Vec<ChioReceipt>,
pub capabilities: Vec<CapabilityLineageRecord>,
pub budget_usage: Vec<BudgetUsageRecord>,
pub incident_reports: Option<Vec<IncidentRecord>>,
}- Receipts carry every signed tool-invocation outcome in the window. They drive boundary pressure, reliability, specialization, and history.
- Capability lineage records snapshots of issued capabilities: parent links, scope, grants, and validity bounds. They drive least privilege and delegation hygiene.
- Budget usage records per-grant invocation counters, keyed by capability id and grant index. They feed resource stewardship.
- Incident reports are timestamps with an optional receipt id, classified as incidents by whatever produced them.
Nonemeans the incident metric is unavailable, which is a different thing from zero incidents.
The scoring entry point
#[must_use]
pub fn compute_local_scorecard(
subject_key: &str,
now: u64,
corpus: &LocalReputationCorpus,
config: &ReputationConfig,
) -> LocalReputationScorecard;The body cuts the corpus into three subject-scoped slices: receipts attributed to the subject that also pass integrity validation, capabilities whose subject is this key, and capabilities this key issued that name a parent. It runs eight metric functions over those slices and folds each result through contribute_metric, which adds weight * value to a running sum and weight to an effective weight sum, and which skips both when the metric is unknown.
let composite_score = if effective_weight_sum > 0.0 {
MetricValue::known(weighted_sum / effective_weight_sum)
} else {
MetricValue::Unknown
};The composite is a weighted mean over the metrics that had a value, not over all eight, so a metric with nothing to observe neither raises nor lowers the result. The divisor is the sum of the weights that actually contributed, and a scorecard reports it as effective_weight_sum so a reader can tell how much of the model was in play.
Invoking the scorer
chio reputation local computes the scorecard for one subject against a receipt database. chio reputation compare evaluates a portable passport against the live local corpus and reports per-credential drift. Both take the subject as bare hex.
# Local scorecard for one subject
chio --json --receipt-db receipts.sqlite3 reputation local \
--subject-public-key <64-hex> \
[--since <unix>] [--until <unix>] [--policy policy.yaml]
# Compare a portable passport against live local state
chio --json --receipt-db receipts.sqlite3 reputation compare \
--subject-public-key <64-hex> --passport passport.json \
[--local-policy policy.yaml] [--verifier-policy verifier.yaml]Expected output
Run against a store holding no receipts for that subject, the scorer returns a scorecard rather than an error. The projection below keeps the envelope and the two decisive scorecard fields; the full report also carries every per-metric struct, each reading "state": "unknown".
$ chio --json --receipt-db ./receipts.db reputation local \
--subject-public-key 80f2b577472e6662f46ac2e029f4b2d1300f889bc767b3de1f7b63a4c562fd8f \
| jq '{subjectKey, scoringSource, weights: .scoring.weights,
$ probationary, probationaryStatus, effectiveScore,
$ composite: .scorecard.composite_score,
$ effectiveWeightSum: .scorecard.effective_weight_sum,
$ importedTrust}'{
"subjectKey": "80f2b577472e6662f46ac2e029f4b2d1300f889bc767b3de1f7b63a4c562fd8f",
"scoringSource": "default",
"weights": {
"boundary_pressure": 0.2,
"resource_stewardship": 0.1,
"least_privilege": 0.15,
"history_depth": 0.1,
"tool_diversity": 0.05,
"delegation_hygiene": 0.15,
"reliability": 0.15,
"incident_correlation": 0.1
},
"probationary": true,
"probationaryStatus": {
"belowReceiptTarget": true,
"belowDayTarget": true
},
"effectiveScore": 0.0,
"composite": {
"state": "unknown"
},
"effectiveWeightSum": 0.0,
"importedTrust": {
"policy": {
"attenuationFactor": 0.5,
"requireProofs": true,
"requireSignerIdentity": true,
"maxSignalAgeDays": 30,
"requiredTrustMode": "bilateral_evidence_share"
},
"signalCount": 0,
"acceptedCount": 0
}
}Three things in that report carry the fail-closed behavior. Every metric is Unknown rather than zero, so effective_weight_sum is 0.0 and the composite takes the MetricValue::Unknown branch above rather than dividing by zero. effectiveScore is 0.0, because the caller-facing number substitutes zero for an unknown composite rather than inventing a middle. And probationary is true with both belowReceiptTarget and belowDayTarget set, which is how a caller tells a subject with a bad record from a subject with no record.
The same two reports are served over trust-control at /v1/reputation/local/{subject_key} and /v1/reputation/compare/{subject_key}.
Default constants
Three of the four module-level constants below name defaults impl Default for ReputationConfig reads; the fourth is the day conversion the decay and history math share. The crate assembles its modules with include!, so a constant declared in lib.rs is in scope for the impls in model.rs.
const SECONDS_PER_DAY: u64 = 86_400;
const DEFAULT_HISTORY_RECEIPT_TARGET: u64 = 1_000;
const DEFAULT_HISTORY_DAY_TARGET: u64 = 30;
const DEFAULT_INCIDENT_PENALTY: f64 = 0.20;The other three defaults are written inline in impl Default for ReputationConfig: target_utilization is 0.75, diversity_cap is 1.0, and temporal_decay_half_life_days is 30.
The eight metric components
Every metric returns a MetricValue, an internally tagged enum that is either Known(f64) or Unknown and appears on the wire as {"state": "known", "value": 0.87} or {"state": "unknown"}. Unknown means the metric had no observable signal. The constructor MetricValue::known runs its input through clamp01, so a component cannot push the composite outside [0, 1] even if its own math overshoots.
The eight weights come from impl Default for ReputationWeights and sum to 1.00:
impl Default for ReputationWeights {
fn default() -> Self {
Self {
boundary_pressure: 0.20,
resource_stewardship: 0.10,
least_privilege: 0.15,
history_depth: 0.10,
tool_diversity: 0.05,
delegation_hygiene: 0.15,
reliability: 0.15,
incident_correlation: 0.10,
}
}
}| Weight key | Default | Contributing field | What it measures | Computed by |
|---|---|---|---|---|
boundary_pressure | 0.20 | deny_ratio | The mean per-policy deny ratio over decayed receipt weights. The metric contributes 1.0 - deny_ratio, so less pressure scores higher. | compute_boundary_pressurescore.rs |
resource_stewardship | 0.10 | fit_score | 1.0 - |average_utilization - target_utilization| across grants that carry a max_invocations cap. | compute_resource_stewardshipscore.rs |
least_privilege | 0.15 | score | Per capability, used_tools / grants times a constraint factor 0.5 + 0.5 * constrained_ratio and an operation factor 0.5 + 0.5 * non_delegate_ratio, then decay-weighted across capabilities. | compute_least_privilegescore.rs |
history_depth | 0.10 | score | The mean of three ratios, each capped at one: receipts against history_receipt_target, span in days against history_day_target, and active days over span days. | compute_history_depthcompare.rs |
tool_diversity | 0.05 | specialization.score | Normalized Shannon entropy over decayed per-tool weights. The scorecard field is specialization while the weight key is tool_diversity, and the contribution is capped at diversity_cap where it is folded in. | compute_specializationcompare.rs |
delegation_hygiene | 0.15 | score | The mean of the scope-reduction, TTL-reduction, and budget-reduction rates across delegations the subject issued against their parents. | compute_delegation_hygienecompare.rs |
reliability | 0.15 | score | Decayed allow weight over allow plus canceled plus incomplete. Denials are excluded entirely, so a denied request does not count against reliability as well as boundary pressure. | compute_reliabilitycompare.rs |
incident_correlation | 0.10 | score | 1.0 - incident_penalty * weighted_incidents, clamped into [0, 1]. Unknown when incident_reports is None. | compute_incident_correlationcompare.rs |
The fold is eight literal calls rather than a loop over a table, so the mapping from a weight key to the field it reads is fixed in code:
contribute_metric(
boundary_pressure
.deny_ratio
.as_option()
.map(|value| 1.0 - value),
config.weights.boundary_pressure,
&mut weighted_sum,
&mut effective_weight_sum,
);
contribute_metric(
resource_stewardship.fit_score.as_option(),
config.weights.resource_stewardship,
&mut weighted_sum,
&mut effective_weight_sum,
);
// ...six more calls, one per metric.Configuration knobs
pub struct ReputationConfig {
pub weights: ReputationWeights,
pub target_utilization: f64,
pub diversity_cap: f64,
pub temporal_decay_half_life_days: u32,
pub history_receipt_target: u64,
pub history_day_target: u64,
pub incident_penalty: f64,
#[serde(default)]
pub trusted_kernel_keys: BTreeSet<String>,
}
impl Default for ReputationConfig {
/// Fail-closed default. The empty `trusted_kernel_keys` set will cause
/// every receipt to fail integrity validation, which yields zero / unknown
/// scores. Callers MUST populate trusted kernel keys via
/// [`ReputationConfig::with_trusted_kernel_keys`] before scoring or the
/// caller's reputation history will be silently filtered out. A
/// `tracing::warn!` is emitted the first time integrity is checked with an
/// empty trust set to surface the misconfiguration.
fn default() -> Self {
Self {
weights: ReputationWeights::default(),
target_utilization: 0.75,
diversity_cap: 1.0,
temporal_decay_half_life_days: 30,
history_receipt_target: DEFAULT_HISTORY_RECEIPT_TARGET,
history_day_target: DEFAULT_HISTORY_DAY_TARGET,
incident_penalty: DEFAULT_INCIDENT_PENALTY,
trusted_kernel_keys: BTreeSet::new(),
}
}
}- History window. History depth normalizes receipt count against
history_receipt_target, 1,000 by default, and the observed day span againsthistory_day_target, 30. Both must saturate, and the days must be filled in, for the metric to reach one. - Temporal decay. Every receipt, capability, and incident weight is
2^(-age / half_life), so an observation one half-life old counts half as much as one made now. - Target utilization. The fit score is the distance from the target, subtracted from one, so underuse and overuse are penalized symmetrically. At the default target, an agent that consumes three quarters of its capped grants scores best.
- Incident penalty. Each decay-weighted incident subtracts
incident_penalty, 0.20 by default, from a base of one. Five incidents happening right now take that metric to zero. - Trusted kernel keys. The hex-encoded set of kernel signing keys whose receipts count.
receipt_integrity_validrejects a receipt whosekernel_keyis outside the set, and also re-derives the content-addressed receipt id, verifies the signature, and verifies the action hash, so a receipt fails before it reaches any metric.
An empty trust set scores nothing
ReputationConfig::default() ships an empty trusted_kernel_keys set, and an empty set rejects every receipt, which collapses every receipt-derived metric to Unknown and the composite with them. Populate it through ReputationConfig::with_trusted_kernel_keys before scoring. The crate emits one tracing::warn! the first time integrity is checked against an empty set, then rejects.Half-life zero disables decay
decay_weight returns 1.0 for every observation when temporal_decay_half_life_days is zero, which is the flat window to use when the corpus is already clipped to the interval that matters.Output shape
pub struct LocalReputationScorecard {
pub subject_key: String,
pub computed_at: u64,
pub boundary_pressure: BoundaryPressureMetrics,
pub resource_stewardship: ResourceStewardshipMetrics,
pub least_privilege: LeastPrivilegeMetrics,
pub history_depth: HistoryDepthMetrics,
pub specialization: SpecializationMetrics,
pub delegation_hygiene: DelegationHygieneMetrics,
pub reliability: ReliabilityMetrics,
pub incident_correlation: IncidentCorrelationMetrics,
pub composite_score: MetricValue,
pub effective_weight_sum: f64,
}Each per-metric struct carries its observation counts next to its score: receipts_observed, policies_observed, capped_grants_observed, capabilities_observed, distinct_tools, delegations_observed. A downstream policy can therefore refuse a high score computed from four receipts, which a bare composite would hide.
The reputation issuance policy
The capability authority is the shipped consumer. enforce_reputation_policy and the read-only inspect_local_reputation_with_read_context in crates/platform/chio-control-plane/src/issuance/reputation.rs call compute_local_scorecard and turn the composite into a ceiling on the capability being issued, in four steps.
- Mark the subject probationary while
history_depth.receipt_countis underprobationary_receipt_countorhistory_depth.span_daysis underprobationary_min_days. Both default to1000receipts and30days. - Take the composite, substituting
0.0when it is unknown, and cap it atprobationary_score_ceiling(default0.60) while probationary. Otherwise it passes through. - Resolve a tier by score range.
resolve_tierscans the sorted tier list from the top and takes the last band the effective score falls into, then falls back to the first tier when no band matches. Ranges are inclusive at both ends, so a score sitting exactly on a boundary resolves to the higher band. - Apply the tier's
max_scopethroughenforce_tier_scope. A requested scope or TTL beyond the ceiling is refused.
The crate carries no tier names of its own. Every tier arrives through the extensions.reputation.tiers block of a HushSpec policy the operator writes, and the materializer sorts them by score-range lower bound, then upper bound, then name. The four-tier ladder in examples/policies/hushspec-reputation.yaml is the worked example the control-plane tests load:
| Tier | Score range | Operations | Invocations | Delegation depth | TTL |
|---|---|---|---|---|---|
probationary | 0.00 to 0.40 | read, get | 50 | 0 | 60 s |
standard | 0.40 to 0.65 | read, get, invoke | 500 | 1 | 300 s |
trusted | 0.65 to 0.85 | read, get, invoke, read_result | 5000 | 3 | 1800 s |
elevated | 0.85 to 1.00 | read, get, invoke, read_result, delegate, subscribe | uncapped | 5 | 3600 s |
That example also carries promotion and demotion blocks with their own thresholds and triggers. The score-to-scope translation documented here reads neither: it is a stateless lookup of the band the effective composite falls into, and the ceiling is whatever max_scope the operator wrote for that band. A policy whose tiers block is empty resolves no tier at all, and issuance fails with reputation issuance policy did not resolve a matching tier.
Reputation feeds and tiers
A second, separate mechanism in the same crate composes signed deltas rather than raw scorecards. A ReputationFeed is a deterministic function from a caller-supplied observation to a ScoreDelta: a feed_id, a value that ScoreDelta::from_value clamps into [0.0, MAX_FEED_DELTA], and the count of observations consumed. A negative or non-finite input becomes a zero delta, so a feed cannot subtract. Two feeds ship: arena_survival and cross_provider_equality. The kernel never invokes one.
compose_deltas takes the per-feed maximum rather than a sum, and returns None for an empty slice so a caller can tell no signal from zero signal. tier_from_deltas maps the composed value onto one of four tiers:
/// Composed-score floor for `tier_1`.
pub const TIER_1_THRESHOLD: f64 = 0.50;
/// Composed-score floor for `tier_2`.
pub const TIER_2_THRESHOLD: f64 = 0.75;
/// Composed-score floor for `tier_3`.
pub const TIER_3_THRESHOLD: f64 = 0.90;
/// Per-feed minimum delta required for `tier_3`. Every shipped feed
/// MUST clear this value independently for the publisher to reach the
/// highest tier; this is the Sybil-resistance mitigation: a flood of
/// arena rounds alone cannot promote a publisher past `tier_2`.
pub const TIER_3_PER_FEED_THRESHOLD: f64 = 0.80;| Tier | What it takes |
|---|---|
tier_0 | The default. No positive evidence required, and the tier an empty delta slice resolves to. |
tier_1 | Composed score at or above 0.50. |
tier_2 | Composed score at or above 0.75. |
tier_3 | Composed score at or above 0.90, every delta at or above 0.80, and at least two distinct feed_id values. |
Because composition is a maximum, one feed can carry a publisher to tier_2 alone. The top tier is the only one with an AND: it counts distinct feed ids, not observations, so a flood of strong deltas from a single source stays capped. Tiers gate marketplace discovery visibility rather than installation: a publisher below a guard's reputation_floor does not appear in a listing, and tier_0 is the default floor for a manifest that names none.
Behavioral profile inputs
The scorecard is one of two signals the platform derives from receipt traffic. The other is the behavioral profile guard in crates/guards/chio-guards/src/behavioral_profile.rs, which carries its EWMA defaults and four per-window metrics:
/// Default EMA smoothing factor. Equivalent to a ~10-sample window.
pub const DEFAULT_EMA_ALPHA: f64 = 0.2;
/// Default sigma threshold above which a window is flagged.
pub const DEFAULT_SIGMA_THRESHOLD: f64 = 2.0;
/// Default rolling window length in seconds.
pub const DEFAULT_WINDOW_SECS: u64 = 60;
/// Default number of historical windows used to prime the baseline
/// before the guard starts emitting signals. Guarantees the z-score
/// has enough history to be meaningful.
pub const DEFAULT_BASELINE_MIN_WINDOWS: u64 = 3;
/// Metric captured per (agent, window).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BehavioralMetric {
/// Total receipts per window.
CallRate,
/// Denies per window.
DenyRate,
/// Distinct tool names per window.
UniqueTools,
/// Approximate parameter entropy per window.
AvgParameterEntropy,
}The guard is advisory. Its evaluate reads one bounded window of receipts, observes the CallRate sample, and returns GuardDecision::allow() whatever the anomaly state. The other three metrics are reachable through observe_sample for a caller feeding the guard out of band. The anomaly evidence rides along for downstream scoring; the guard stops nothing.
A window is flagged only when the baseline holds at least baseline_min_windows prior samples and the z-score exceeds sigma_threshold in absolute value:
let z = robust_z_score(&entry.state, sample);
let seen_enough = entry.state.sample_count >= self.config.baseline_min_windows;
let anomaly = seen_enough
&& z.map(|z| z.abs() > self.config.sigma_threshold)
.unwrap_or(false);robust_z_score floors the effective standard deviation at sqrt(max(ema_mean, 1)), a Poisson-style floor for count metrics. Without it a steady rate whose EWMA variance has collapsed to zero would divide by nothing and never flag; the test spike_fifty_x_triggers_anomaly pins the behavior with a fifty-fold jump over a steady baseline.
The compliance score
Alongside the reputation scorecard sits the compliance score in crates/kernel/chio-kernel/src/compliance_score.rs. It answers a different question, whether the agent stayed inside its policies over the window, and answers it as an integer from 0 to COMPLIANCE_SCORE_MAX. Five factor weights sum to that maximum, and each factor deducts its weight in proportion to a rate:
/// Maximum possible compliance score.
pub const COMPLIANCE_SCORE_MAX: u32 = 1000;
/// Weight (maximum-deducted points) for the deny-rate factor.
pub const WEIGHT_DENY_RATE: u32 = 300;
/// Weight for the revocation factor.
pub const WEIGHT_REVOCATION: u32 = 300;
/// Weight for the velocity-anomaly factor.
pub const WEIGHT_VELOCITY_ANOMALY: u32 = 150;
/// Weight for the policy-coverage factor.
pub const WEIGHT_POLICY_COVERAGE: u32 = 150;
/// Weight for the attestation-freshness factor.
pub const WEIGHT_ATTESTATION_FRESHNESS: u32 = 100;
/// Default staleness threshold (seconds) beyond which the attestation
/// freshness factor is fully deducted. Ninety days mirrors the default
/// receipt-retention window in [`crate::receipt_store::RetentionConfig`].
pub const DEFAULT_ATTESTATION_STALENESS_SECS: u64 = 7_776_000;| Factor | Max deduction | Rate driver |
|---|---|---|
| Deny rate | 300 | deny_receipts / total_receipts, zero when no receipts were seen. |
| Revocation | 300 | revoked_capabilities / observed_capabilities, floored at 1.0 when any_revoked is set and treat_any_revocation_as_full is on. |
| Velocity anomaly | 150 | anomalous_velocity_windows / velocity_windows. |
| Policy coverage | 150 | 1 - avg(checkpoint_coverage, lineage_coverage), and zero when the report matched no receipts, so a new agent is not penalized for silence. |
| Attestation freshness | 100 | age_secs / attestation_staleness_secs, clamped at one. A missing attestation age deducts the full 100 rather than none. |
The compliance score carries one hard rule the reputation score has no equivalent for. The default config sets revocation_ceiling to 499, and any revocation caps the output there whatever the other four factors say:
let score = if inputs.any_revoked || inputs.revoked_capabilities > 0 {
raw_score.min(config.revocation_ceiling)
} else {
raw_score
};The unit test revocation_flag_drives_score_below_500 pins that outcome.
Worked example
The corpus below is illustrative, chosen to exercise every metric. It stands in for an agent with a fourteen-day history scored at now = 1_715_000_000 under the default config.
Receipts (180 total):
168 Allow
10 Deny (split across 2 policies)
2 Canceled
All within the last 14 days
Capabilities issued to the subject: 4
- 3 capped at max_invocations
- 1 with constraint expressions
- All operations exclude Delegate
Budget usage:
Average utilization across capped grants: 0.62
Delegations the subject issued: 2
- Both reduce scope vs parent
- 1 reduces TTL
- 1 reduces budget
Incident reports: NoneComponent values, rounded:
boundary_pressure.deny_rationear 0.06 across the two policies, contributing1 - 0.06 = 0.94resource_stewardship.fit_score=1 - |0.62 - 0.75| = 0.87least_privilege.scorenear 0.70, with used below granted and non-trivial constraint and non-delegate factorshistory_depth.score=avg(180/1000, 14/30, activity_ratio)near 0.41specialization.scorenear 0.85, from the entropy across used toolsdelegation_hygiene.score=avg(1.0, 0.5, 0.5) = 0.67reliability.score=168 / (168 + 2)near 0.99, with the ten denials excludedincident_correlation=Unknown, and so excluded
Seven metrics contributed, so the effective weight sum is 0.90 rather than 1.00:
composite = (0.20 * 0.94 + 0.10 * 0.87 + 0.15 * 0.70 + 0.10 * 0.41
+ 0.05 * 0.85 + 0.15 * 0.67 + 0.15 * 0.99) / 0.90
= 0.7125 / 0.90
~ 0.79That sits above the minimum_approve_reputation_score of 0.6 that chio-underwriting defaults to, so the agent clears the approval floor and the credit scorecard dimensions shape the terms. Had incident reports been present and poor, the eighth weight would have re-entered the divisor and pulled the composite down twice over: once through its own low value, and once because the denominator grew.
Privacy and tenancy
Scores are tenant-scoped by construction. Org A scores from Org A's corpus and Org B from Org B's. The function is the same and the inputs are not, so the numbers differ, and neither org can see the other's receipts to reconcile them.
Reputation crosses an organizational boundary as a signed credential rather than a shared score. Org A signs a credential carrying its scorecard, the agent presents it to Org B, and build_imported_reputation_signal applies Org B's own import policy: an attenuation factor of 0.50 by default, a signal age ceiling of 30 days, required proofs, a required signer identity, and a required trust mode of bilateral_evidence_share. The capture above shows those five defaults in the importedTrust.policy block. Chio defines no global reputation score: every relying party computes its own, or imports and attenuates one.
See also
- Credit Scorecards for how this composite plus settlement history collapses into a band and a confidence level.
- Portable Reputation for the import path, the attenuation policy, and cross-issuer migration.
- Agent Passports for the credential format a scorecard leaves the issuing tenant in.
- Compliance Certificates for the per-session record over the same receipts.
- Kernel · Session for where the behavioral profile guard runs in the request lifecycle.