// socialweb.computer market policies as step protocol modules. // One contract for every policy: entry `evaluate`, input = the kcp policy action's inputs // { args, perspective, mode: "scope"|"full", selfDid, counterpartyDid, subjectDid, rootRequesterDid, // rfp?, bid?, accept? } plus an optional host-seeded `snapshot` { operatorOf, vouchedBy }; result = // kcp's Verdict { allow, violations: [{ msg, policyId }] }. // // Decisions follow socialweb-computer-kcp (atlibs-bidder-fixes-rc159): the trust snapshot is // trust.BuildSnapshotWith (internal/policyengine/trust/trust.go:316) with the default association // kinds (claim + association, trust/association.go:23), the decisions are deno-typescript-shared // trust.ts (onlyMeAllows, tangledVouchAllows, mutualsAllows), scope.ts scopeDecide and the work // policies bid-payload.ts / under-4-cpus.ts. Without a `snapshot` the module gathers it itself with // public com.atproto.repo.getRecord reads: every handler call re-runs the decision over the reads // it has, asks for the ones it is missing, and answers as soon as nothing is missing. use abi::{Call, Event, Handle, Run}; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; pub const GET_RECORD: &str = "com.atproto.repo.getRecord"; const VOUCH: &str = "sh.tangled.graph.vouch"; const BADGE: &str = "com.publicdomainrelay.temp.badgeBlueKeys"; const ASSOCIATION: &str = "com.publicdomainrelay.temp.market.association"; const ACCEPT: &str = "com.publicdomainrelay.temp.market.accept"; const RECEIPT: &str = "com.publicdomainrelay.temp.market.receipt"; const VM_DELETE: &str = "com.publicdomainrelay.temp.compute.events.vm.delete"; const VM_TERMINATED: &str = "com.publicdomainrelay.temp.compute.events.vm.terminated"; const BIDS_FREE: &str = "com.publicdomainrelay.temp.market.bids.free"; const BIDDER_ASSOCIATE: &str = "bidder_associate"; const REQUESTER_ASSOCIATE: &str = "requester_associate"; #[derive(Clone, Copy, PartialEq)] pub enum Kind { OnlyMe, TangledVouch, Mutuals, Open, BidPayload, UnderCpus, } pub struct Policy { pub name: &'static str, pub kind: Kind, } impl Policy { // action-common.ts policyPerspective: a work policy's first perspective, else the name's side. fn perspective(&self) -> &'static str { match self.kind { Kind::BidPayload => "requester", Kind::UnderCpus => "bidder", _ if self.name.starts_with("bidder-") => "bidder", _ => "requester", } } fn trust(&self) -> bool { matches!(self.kind, Kind::OnlyMe | Kind::TangledVouch | Kind::Mutuals) } } /// The handler every policy module exports as `evaluate`. pub fn evaluate(policy: &Policy, run: Run) -> Result { // got: at-uri -> getRecord output (null: no such record, {$error}: the read failed); // issued: call id -> at-uri (call ids are short). let mut got = run.state.get("got").and_then(Value::as_object).cloned().unwrap_or_default(); let mut issued = run.state.get("issued").and_then(Value::as_object).cloned().unwrap_or_default(); match run.event { Event::Start => {} Event::Result { id, result, .. } => { let Some(uri) = issued.get(&id).and_then(Value::as_str) else { return Ok(Handle::new()) }; let value = match result.output() { Ok(output) => output.clone(), Err(error) if error.contains("RecordNotFound") => Value::Null, Err(error) => json!({ "$error": error }), }; got.insert(uri.to_string(), value); } _ => return Ok(Handle::new()), } let mut reads = Reads { got: &got, need: Vec::new() }; if let Some(verdict) = decide(policy, &run.args, &mut reads)? { return Ok(Handle::new().result(verdict).done()); } let need: Vec = reads.need.into_iter().filter(|uri| !issued.values().any(|u| u == uri)).collect(); if need.is_empty() && issued.values().all(|uri| uri.as_str().is_some_and(|u| got.contains_key(u))) { return Err("policy stalled: no reads pending and no verdict".into()); } let mut out = Handle::new(); for uri in need { let id = format!("r{}", issued.len()); let (repo, collection, rkey) = split(&uri); out = out.call(Call::new(&id, GET_RECORD).params(json!({ "repo": repo, "collection": collection, "rkey": rkey })).service(repo)); issued.insert(id, json!(uri)); } Ok(out.state(json!({ "got": got, "issued": issued }))) } fn verdict(policy: &Policy, deny: Option) -> Value { match deny { None => json!({ "allow": true, "violations": [] }), Some(msg) => json!({ "allow": false, "violations": [{ "msg": msg, "policyId": policy.name }] }), } } fn text<'a>(input: &'a Value, key: &str) -> &'a str { input[key].as_str().map(str::trim).unwrap_or("") } // Ok(None): a read is missing (asked for in reads.need). fn decide(policy: &Policy, input: &Value, reads: &mut Reads) -> Result, String> { let self_did = text(input, "selfDid"); if self_did.is_empty() { return Err("selfDid is required (the evaluator's own DID)".into()); } let perspective = match text(input, "perspective") { "" => policy.perspective(), p => p, }; let args = &input["args"]; if input["mode"] == "scope" { // action-common.ts evaluateScope: the counterparty is the subject; work policies abstain in // scopeDecide and their full evaluation then sees no records, so they allow. let cp = text(input, "counterpartyDid"); if cp.is_empty() { return Err("scope mode requires counterpartyDid".into()); } if !policy.trust() { return Ok(Some(verdict(policy, None))); } let Some(snapshot) = snapshot_for(input, reads, perspective, self_did, cp) else { return Ok(None) }; let deny = (!allows(policy.kind, &snapshot, self_did, cp)).then(|| format!("scope denied by {}", policy.name)); return Ok(Some(verdict(policy, deny))); } // ctx.ts createPolicyCtx: subject from the records when not given, counterparty defaults to it. let subject = match text(input, "subjectDid") { "" if perspective == "requester" => author(&input["bid"]), "" if input["rfp"].is_object() => author(&input["rfp"]), "" => author(&input["accept"]), s => s, }; let cp = match text(input, "counterpartyDid") { "" => subject, c => c, }; Ok(match policy.kind { Kind::Open => Some(verdict(policy, None)), Kind::OnlyMe | Kind::TangledVouch | Kind::Mutuals => { // trust.ts evaluateTrust if cp.is_empty() { return Ok(Some(verdict(policy, Some("no counterparty to evaluate".into())))); } if !subject.is_empty() && subject != cp { return Ok(Some(verdict(policy, Some("the subject and the counterparty differ".into())))); } let Some(snapshot) = snapshot_for(input, reads, perspective, self_did, cp) else { return Ok(None) }; let msg = match policy.kind { Kind::OnlyMe => "operator mismatch", Kind::TangledVouch => "not vouched", _ => "not mutual follows", }; Some(verdict(policy, (!allows(policy.kind, &snapshot, self_did, cp)).then(|| msg.into()))) } Kind::BidPayload => { // bid-payload.ts: the bid's payload collection must be allowed (free bids by default). let Some(bid) = hydrate(reads, &input["bid"]) else { return Ok(None) }; let Some(nsid) = payload_ref(&bid).map(|uri| split(uri).1.to_string()) else { return Ok(Some(verdict(policy, None))) }; let free = [json!(BIDS_FREE)]; let allowed = args["allowedPayloadNsids"].as_array().map(Vec::as_slice).unwrap_or(&free); let deny = (!allowed.contains(&json!(nsid))).then(|| format!("bid payload type {nsid} not allowed")); Some(verdict(policy, deny)) } Kind::UnderCpus => { // under-4-cpus.ts: the RFP's payload cpus must be at most args.maxCpus (a number, else 4). let max = args["maxCpus"].as_f64().unwrap_or(4.0); let Some(rfp) = hydrate(reads, &input["rfp"]) else { return Ok(None) }; let Some(uri) = payload_ref(&rfp) else { return Ok(Some(verdict(policy, None))) }; let payload = match &input["rfp"]["payload"] { p if truthy(p) => p.clone(), _ => match reads.get(uri) { None => return Ok(None), Some(Rec::Found(output)) => output.get("value").cloned().unwrap_or_else(|| json!({})), Some(Rec::Absent) => return Ok(Some(verdict(policy, Some(format!("failed to resolve demand payload: Error: getRecord {uri} failed: RecordNotFound"))))), Some(Rec::Failed(e)) => return Ok(Some(verdict(policy, Some(format!("failed to resolve demand payload: Error: getRecord {uri} failed: {e}"))))), }, }; let deny = payload["cpus"].as_f64().filter(|cpus| *cpus > max).map(|cpus| format!("VM needs {} cpus; this bidder's cap is {}", js_number(cpus), js_number(max))); Some(verdict(policy, deny)) } }) } fn truthy(value: &Value) -> bool { match value { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64().is_some_and(|n| n != 0.0), Value::String(s) => !s.is_empty(), _ => true, } } fn js_number(n: f64) -> String { if n.fract() == 0.0 && n.abs() < 1e21 { format!("{}", n as i64) } else { format!("{n}") } } fn author(record: &Value) -> &str { record["uri"].as_str().map(|uri| split(uri).0).unwrap_or("") } // ctx.ts hydrate: a record given as a ref is read; a failed read keeps the ref without a value. fn hydrate(reads: &mut Reads, record: &Value) -> Option { if !record.is_object() || truthy(&record["value"]) { return Some(record["value"].clone()); } let Some(uri) = record["uri"].as_str() else { return Some(Value::Null) }; Some(match reads.get(uri)? { Rec::Found(output) => output.get("value").cloned().unwrap_or_else(|| json!({})), _ => Value::Null, }) } // ctx.ts deriveOffer / deriveDemand: the record's payload strongRef uri, when it has uri and cid. fn payload_ref(value: &Value) -> Option<&str> { let payload = &value["payload"]; (truthy(&payload["uri"]) && truthy(&payload["cid"])).then(|| payload["uri"].as_str()).flatten() } // ---- trust decisions --------------------------------------------------------------------------- pub struct Snapshot { operator_of: Map, vouched_by: Map, } impl Snapshot { fn operator(&self, did: &str) -> Option<&str> { self.operator_of.get(did).and_then(Value::as_str).filter(|op| !op.is_empty()) } // A self-vouch never counts (BuildSnapshotWith skips subject == voucher, trust.go:391). fn vouched(&self, voucher: &str, subject: &str) -> bool { voucher != subject && self.vouched_by.get(voucher).and_then(Value::as_array).is_some_and(|l| l.iter().any(|d| d == subject)) } } // trust.ts onlyMeAllows / tangledVouchAllows / mutualsAllows over scope.ts buildTrustSnapshot. fn allows(kind: Kind, s: &Snapshot, self_did: &str, did: &str) -> bool { let trusted = [self_did, s.operator(self_did).unwrap_or(self_did)]; let op = s.operator(did); let only_me = did == self_did || op.is_some_and(|op| trusted.contains(&op)); let trust_vouched = || trusted.iter().any(|t| s.vouched(t, did) || op.is_some_and(|op| s.vouched(t, op))); let vouches_trusted = || { let vouchers: &[&str] = match op { Some(op) if op != did => &[did, op], _ => &[did], }; vouchers.iter().any(|v| trusted.iter().any(|m| s.vouched(v, m))) }; match kind { Kind::OnlyMe => only_me, Kind::TangledVouch => only_me || trust_vouched(), Kind::Mutuals => only_me || (trust_vouched() && vouches_trusted()), _ => true, } } fn snapshot_for(input: &Value, reads: &mut Reads, perspective: &str, self_did: &str, cp: &str) -> Option { if let Some(seeded) = input["snapshot"].as_object() { let map = |key: &str| seeded.get(key).and_then(Value::as_object).cloned().unwrap_or_default(); return Some(Snapshot { operator_of: map("operatorOf"), vouched_by: map("vouchedBy") }); } // executor/ghalite.go snapshotServices: each side's operator is resolved under its own role. let (self_svc, cp_svc) = if perspective == "requester" { (REQUESTER_ASSOCIATE, BIDDER_ASSOCIATE) } else { (BIDDER_ASSOCIATE, REQUESTER_ASSOCIATE) }; let now = instant(text(input, "now")); build_snapshot(reads, self_did, cp, self_svc, cp_svc, now) } // ---- trust.BuildSnapshotWith over getRecord reads ---------------------------------------------- pub enum Rec<'a> { Absent, Failed(&'a str), Found(&'a Value), } pub struct Reads<'a> { got: &'a Map, need: Vec, } impl<'a> Reads<'a> { // None: not read yet (now asked for). fn get(&mut self, uri: &str) -> Option> { match self.got.get(uri) { None => { if !self.need.iter().any(|u| u == uri) { self.need.push(uri.to_string()); } None } Some(Value::Null) => Some(Rec::Absent), Some(v) if v.get("$error").is_some() => Some(Rec::Failed(v["$error"].as_str().unwrap_or("call failed"))), Some(v) => Some(Rec::Found(v)), } } // atproto.Client.GetRecordAt + cid check (trust/association.go pinnedRecord): the value, or None. fn pinned(&mut self, uri: &str, cid: &str) -> Option> { Some(match self.get(uri)? { Rec::Found(output) if output["cid"] == cid => Some(&output["value"]), _ => None, }) } } fn uri(repo: &str, collection: &str, rkey: &str) -> String { format!("at://{repo}/{collection}/{rkey}") } fn split(uri: &str) -> (&str, &str, &str) { let mut parts = uri.strip_prefix("at://").unwrap_or(uri).splitn(3, '/'); (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or("")) } fn strong_ref(value: &Value) -> Option<(&str, &str)> { let uri = value["uri"].as_str().filter(|u| u.starts_with("at://"))?; let cid = value["cid"].as_str().filter(|c| !c.is_empty())?; Some((uri, cid)) } #[derive(PartialEq)] struct Claim<'a> { operator: &'a str, subject: &'a str, service: &'a str, } // trust.go claimOf fn claim_of(value: &Value) -> Option> { let field = |k: &str| value[k].as_str().map(str::trim).unwrap_or(""); let claim = Claim { operator: field("challenge"), subject: field("keyId"), service: field("service") }; (claim.operator.starts_with("did:") && claim.subject.starts_with("did:")).then_some(claim) } fn claim_rkey(service: &str, subject: &str) -> String { format!("{service}-{}", subject.replace(':', "-")) } fn sha(parts: &[&[u8]]) -> [u8; 32] { let mut hasher = Sha256::new(); for part in parts { hasher.update(part); } hasher.finalize().into() } fn hex16(sum: &[u8; 32]) -> String { sum[..16].iter().map(|b| format!("{b:02x}")).collect() } // market.ReceiptRKey, SessionRKey("delete", receipt) (ReleasePayloadRKey), EventRKeys(TerminationKey) payload rkey. fn receipt_rkey(accept_uri: &str, accept_cid: &str) -> String { format!("receipt-{}", hex16(&sha(&[accept_uri.trim().as_bytes(), b"\n", accept_cid.trim().as_bytes()]))) } fn release_rkey(receipt_uri: &str) -> String { const ALPHABET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; let sum = sha(&[b"delete", &[0], receipt_uri.as_bytes()]); sum[..13].iter().map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char).collect() } fn termination_rkey(receipt_uri: &str) -> String { format!("termination-payload-{}", hex16(&sha(&[b"terminated:", receipt_uri.as_bytes()]))) } // trust.go ResolveOperatorDidWith / operatorOf: Some(Some(op)) a verified operator, Some(None) none. fn operator(r: &mut Reads, did: &str, service: &str, now: Option<(i64, u32)>) -> Option> { let Rec::Found(mirror) = r.get(&uri(did, BADGE, "association"))? else { return Some(None) }; let value = &mirror["value"]; let Some(want) = claim_of(value).filter(|c| c.subject == did && c.operator != did && c.service == service) else { return Some(None) }; let held = if let Some(proof) = value.as_object().and_then(|o| o.get("proof")) { proof_held(r, &want, proof, now)? } else { let association = association_held(r, &want, now); let claim = claim_held(r, &want); match (association?, claim?) { (Err(()), _) => false, (Ok(held), claim) => held || claim, } }; Some(held.then(|| want.operator.to_string())) } // trust/association.go Matches for the default kinds. fn matches(collection: &str, value: &Value, want: &Claim) -> bool { if !value.is_object() || want.operator.is_empty() || want.subject.is_empty() || want.operator == want.subject || value["$type"] != collection { return false; } match collection { BADGE => claim_of(value).is_some_and(|c| c == *want), ASSOCIATION => value["subject"] == want.subject && value["service"] == want.service, _ => false, } } fn proof_held(r: &mut Reads, want: &Claim, proof: &Value, now: Option<(i64, u32)>) -> Option { let Some((proof_uri, cid)) = strong_ref(proof) else { return Some(false) }; let (repo, collection, _) = split(proof_uri); // Posts (bskyPost, delvePost) are association kinds kcp accepts only when configured; the // default Association{} (what executor/ghalite.go uses) accepts claim and association. if repo != want.operator || (collection != BADGE && collection != ASSOCIATION) { return Some(false); } let Some(value) = r.pinned(proof_uri, cid)? else { return Some(false) }; if !matches(collection, value, want) { return Some(false); } scope_held(r, value, want, now) } // Err(()): the read failed, which kcp treats as no operator at all (no fallback to the claim). fn association_held(r: &mut Reads, want: &Claim, now: Option<(i64, u32)>) -> Option> { let value = match r.get(&uri(want.operator, ASSOCIATION, &claim_rkey(want.service, want.subject)))? { Rec::Found(output) => &output["value"], Rec::Absent => return Some(Ok(false)), Rec::Failed(_) => return Some(Err(())), }; if !matches(ASSOCIATION, value, want) { return Some(Ok(false)); } scope_held(r, value, want, now).map(Ok) } fn claim_held(r: &mut Reads, want: &Claim) -> Option { Some(match r.get(&uri(want.operator, BADGE, &claim_rkey(want.service, want.subject)))? { Rec::Found(output) => claim_of(&output["value"]).is_some_and(|c| c == *want), _ => false, }) } fn scope_held(r: &mut Reads, value: &Value, want: &Claim, now: Option<(i64, u32)>) -> Option { let fields = value.as_object(); let scoped = fields.is_some_and(|f| f.contains_key("contract")); let expiring = fields.is_some_and(|f| f.contains_key("expiresAt")); let expires = value["expiresAt"].as_str().unwrap_or(""); if scoped && expires.is_empty() { return Some(false); } if expiring && !matches!((now, instant(expires)), (Some(now), Some(deadline)) if now < deadline) { return Some(false); } if !scoped { return Some(true); } let Some((contract, cid)) = strong_ref(&value["contract"]) else { return Some(false) }; contract_held(r, contract, cid, want.operator) } // trust/association.go contractHeld: a held, unreleased, unterminated market.accept of the operator. fn contract_held(r: &mut Reads, contract: &str, cid: &str, operator: &str) -> Option { let (repo, collection, _) = split(contract); if repo != operator || collection != ACCEPT { return Some(false); } let Some(accept) = r.pinned(contract, cid)? else { return Some(false) }; let Some((bid, _)) = strong_ref(&accept["bid"]) else { return Some(false) }; let bidder = split(bid).0; if !bidder.starts_with("did:") { return Some(false); } let receipt_uri = uri(bidder, RECEIPT, &receipt_rkey(contract, cid)); let Rec::Found(receipt) = r.get(&receipt_uri)? else { return Some(false) }; if receipt["uri"] != receipt_uri.as_str() || strong_ref(&receipt["value"]["accept"]) != Some((contract, cid)) { return Some(false); } let released = r.get(&uri(operator, VM_DELETE, &release_rkey(&receipt_uri))); let terminated = r.get(&uri(bidder, VM_TERMINATED, &termination_rkey(&receipt_uri))); Some(matches!((released?, terminated?), (Rec::Absent, Rec::Absent))) } // trust.go Vouches: a vouch record (rkey = subject) that is not a denouncement. fn vouches(r: &mut Reads, voucher: &str, subject: &str) -> Option { if voucher.is_empty() || !subject.starts_with("did:") { return Some(false); } Some(match r.get(&uri(voucher, VOUCH, subject))? { Rec::Found(output) => output["value"]["kind"] != "denounce", _ => false, }) } fn distinct<'a>(values: &[&'a str]) -> Vec<&'a str> { let mut out: Vec<&str> = Vec::new(); for v in values { if !v.is_empty() && !out.contains(v) { out.push(v); } } out } fn build_snapshot(r: &mut Reads, self_did: &str, cp: &str, self_svc: &str, cp_svc: &str, now: Option<(i64, u32)>) -> Option { let self_op = operator(r, self_did, self_svc, now); let cp_op = if cp.is_empty() || cp == self_did { Some(None) } else { operator(r, cp, cp_svc, now) }; let self_op = self_op?.unwrap_or_else(|| self_did.to_string()); let cp_op = cp_op?; let co = cp_op.as_deref().unwrap_or(""); let mut operator_of = Map::new(); operator_of.insert(self_did.into(), json!(self_op)); if !co.is_empty() { operator_of.insert(cp.into(), json!(co)); } let trusted = distinct(&[self_did, &self_op]); let vouchers = distinct(&[&trusted[..], &[cp, co]].concat()); let subjects = distinct(&[self_did, &self_op, cp, co]); let mut pending = false; let mut vouched_by = Map::new(); for voucher in &vouchers { let mut list = Vec::new(); for subject in subjects.iter().filter(|s| *s != voucher) { match vouches(r, voucher, subject) { None => pending = true, Some(true) => list.push(json!(subject)), Some(false) => {} } } vouched_by.insert(voucher.to_string(), Value::Array(list)); } (!pending).then_some(Snapshot { operator_of, vouched_by }) } // RFC 3339 (time.RFC3339Nano) to (unix seconds, nanoseconds). fn instant(s: &str) -> Option<(i64, u32)> { let b = s.as_bytes(); let num = |from: usize, to: usize| -> Option { s.get(from..to)?.parse().ok().filter(|_| b[from..to].iter().all(u8::is_ascii_digit)) }; if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' { return None; } let (y, mo, d, h, mi, sec) = (num(0, 4)?, num(5, 7)?, num(8, 10)?, num(11, 13)?, num(14, 16)?, num(17, 19)?); if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || sec > 59 { return None; } let mut i = 19; let mut nanos = 0u32; if b[i] == b'.' { let start = i + 1; i = start; while i < b.len() && b[i].is_ascii_digit() { i += 1; } if i == start || i - start > 9 { return None; } nanos = format!("{:0<9}", &s[start..i]).parse().ok()?; } let offset = match b.get(i)? { b'Z' if i + 1 == b.len() => 0, sign @ (b'+' | b'-') if i + 6 == b.len() && b[i + 3] == b':' => { let minutes = num(i + 1, i + 3)? * 60 + num(i + 4, i + 6)?; if *sign == b'+' { minutes * 60 } else { -minutes * 60 } } _ => return None, }; let yy = if mo <= 2 { y - 1 } else { y }; let era = yy.div_euclid(400); let yoe = yy - era * 400; let doy = (153 * ((mo + 9) % 12) + 2) / 5 + d - 1; let days = era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468; Some((days * 86400 + h * 3600 + mi * 60 + sec - offset, nanos)) }