PlatformCross-Agent Disclosure
Swarm
Selective Disclosure
Open four fields of a signed receipt and prove the rest were signed with them. The projection table, the proof package, the refusals.
Reveal-set disclosure, and only that
chio-selective-disclosure projects a receipt into a BBS message vector, signs the vector, and verifies a proof that opens a chosen subset of it. The bbs cargo feature is opt-in: default = [] and bbs = ["dep:affinidi-bbs"] (Cargo.toml:20-21). Hidden comparisons, VC Data Integrity interop and zkVM proofs are outside that surface, and the buyer-package verifier says so in code rather than in prose: it refuses a package that claims any of them. What a verifier requires around a finished proof, the capsule and the signed subgraph and the leakage ledger that must agree before the proof counts as evidence, is Disclosure Lineage.What a package may claim
A proof package carries a four-flag claim block. Three of the four flags are refusals: setting one makes validate_claims return UnsupportedClaim before any cryptography runs, and clearing the fourth does the same (chio-attest-buyer-core/src/proof_package.rs:52-71).
pub struct ChioProofClaims {
pub bbs_reveal_set: bool,
pub hidden_range_predicates: bool,
pub vc_data_integrity_bbs: bool,
pub zkvm: bool,
}| Flag | Accepted value | Refusal text when it disagrees |
|---|---|---|
bbsRevealSet | true | bbs reveal-set support must be claimed for this package |
hiddenRangePredicates | false | hidden range predicates are not supported by this package |
vcDataIntegrityBbs | false | VC Data Integrity BBS interop is not supported by this package |
zkvm | false | zkVM support is not supported by this package |
The disclosure spec draws the same line in words. Its scope section files a predicate language frozen at three operators, and predicate verification for hidden comparisons, under target scope still open (spec/CHIO_SELECTIVE_DISCLOSURE.md:63-67), and its opening paragraph puts hidden range predicates, VC Data Integrity interop and zkVM proofs outside the implemented slice (:12-13). Read the claim block as the machine-checkable form of that sentence.
Two signatures, one body
Ed25519 over canonical JSON stays authoritative. BBS is a secondary commitment over the same body, taken through a parallel projection so the two never have to agree on an encoding. The keys do not compose: BBS runs on BLS12-381 under the ciphersuite pinned at BBS_CIPHERSUITE_SHA256 (lib.rs:69), with its own per-issuer keypair, while the receipt signature stays on the kernel’s Ed25519 key. Message and header hashing are domain separated by chio.bbs.message.v1 and chio.bbs.header.v1 (lib.rs:72-74).
Three projection tables ship, one per subject, each frozen behind its own version constant (lib.rs:58-62): chio.bbs-projection.receipt.v1 over a ChioReceiptBody, chio.bbs-projection.workflow.v1 over a WorkflowReceiptBody, and chio.bbs-projection.step.v1 over a single StepRecord. A receipt that carries a bbs_projection_version naming anything else is refused with ProjectionVersionMismatch (lib.rs:251-257).
Receipt body to message vector
A projection is a version, a digest of the subject, and an ordered message list. Nothing else: no subject copy, no schema id, no notes column.
/// A typed BBS projection over a Chio receipt-like object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Projection {
pub version: String,
pub subject_sha256_hex: String,
pub messages: Vec<ProjectionMessage>,
}/// One projected message in a Chio BBS vector.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionMessage {
pub index: u16,
pub field: String,
pub encoding: String,
pub bytes_hex: String,
pub wholesale_only: bool,
}project_receipt_body (lib.rs:247-356) pushes fourteen messages in alphabetical order by serde field name. Order is not a policy choice made per deployment; it is the literal order of the fourteen push_message calls, and push_message assigns each index from the vector length as it goes (encoding.rs:102-116).
| Index | Field | Encoding | wholesale_only |
|---|---|---|---|
| 0 | action | H | true |
| 1 | capability_id | S | false |
| 2 | content_hash | Hx | false |
| 3 | decision | H | true |
| 4 | evidence | H | true |
| 5 | id | S | false |
| 6 | kernel_key | H | false |
| 7 | metadata | H | true |
| 8 | policy_hash | Hx | false |
| 9 | tenant_id | Opt<S> | false |
| 10 | timestamp | U64 | false |
| 11 | tool_name | S | false |
| 12 | tool_server | S | false |
| 13 | trust_level | S | false |
Four encodings, each a byte recipe rather than a type name, all in encoding.rs. S is the field’s raw UTF-8 bytes. Opt<S> is the same, or the single byte 0x00 when the option is absent, so a missing tenant is a distinct message rather than an empty one (:88-92). U64 is eight little-endian bytes (:94-96). H is SHA-256 over the canonical JSON of a structured value (:24-26), which is why a wholesale-only row admits no clause reaching inside it: the message is the digest, not the sub-body. Hx is the thirty-two bytes decoded from an existing digest field.
Hx is also the strictest input check on the path. It takes the field verbatim and requires sixty-four lowercase hexadecimal characters, so a content_hash or policy_hash carrying an algorithm prefix fails projection rather than producing a wrong commitment:
pub(crate) fn decode_hx_field(field: &str, raw: &str) -> Result<Vec<u8>, SelectiveDisclosureError> {
if raw.is_empty() {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: "empty string is not a SHA-256 hex digest".to_string(),
});
}
if !is_lower_sha256_hex(raw) {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: "expected 64 lowercase SHA-256 hex characters".to_string(),
});
}
let bytes = hex::decode(raw).map_err(|e| SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: e.to_string(),
})?;
if bytes.len() != 32 {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: format!("decoded length {} bytes does not equal 32", bytes.len()),
});
}
Ok(bytes)
}The same rule reaches kernel_key from the other side. It is a PublicKey (chio-core-types/src/receipt/body.rs:141), and an Ed25519 PublicKey renders as bare lowercase hex with no prefix (crypto.rs:575-583). The parser strips only p256:, p384:, hybrid: and a bare 0x (crypto.rs:401-432), so any other prefix reaches hex::decode and fails there.
The workflow table has the same fourteen slots over different fields, alphabetical by the same rule (lib.rs:362-464): agent_id, capability_id, completed_at, duration_ms, id, kernel_key, outcome, schema, session_id, skill_id, skill_version, started_at, steps, total_cost. Three are wholesale-only: outcome, steps and total_cost.
The proof package
A proof is the reveal set plus the BBS proof bytes. It carries the opened messages verbatim and the issuer material needed to check them, and nothing about the hidden ones except their count.
/// Buyer or auditor proof package carrying only disclosed fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectiveDisclosureProof {
pub schema: String,
pub projection_version: String,
pub subject_sha256_hex: String,
pub ciphersuite: String,
pub issuer_fingerprint: String,
pub issuer_public_key_hex: String,
pub message_count: usize,
pub disclosed_indices: Vec<u16>,
pub disclosed: Vec<DisclosedMessage>,
pub proof_nonce_hex: String,
pub proof_bytes_hex: String,
}The three-vendor fixture ships one. It opens four of the workflow projection’s fourteen messages, indices 4, 8, 9 and 10, which the table above names id, session_id, skill_id and skill_version. Every other message stays committed and unopened, including steps, which is where the per-step costs and the destructive flag live.
{
"schema": "chio.attest.selective-disclosure-proof.v1",
"projection_version": "chio.bbs-projection.workflow.v1",
"subject_sha256_hex": "0a29c62e2ec376a12d147bd3f9ffdd332a19792ebd15d035ae5684bc894e4710",
"ciphersuite": "BBS_BLS12381G1_XMD:SHA-256_SSWU_RO_",
"issuer_fingerprint": "70113a55c73186df1b5812a3c421f1ee8061acfda7fb4009f5a5432102ddaca3",
"issuer_public_key_hex": "ac96b29b82fd2aafa60ec4a4cf720073e1e8fe232f24cb0125b3e89397176e66c170794a6cec5b6440b00fdf88d5851b0d08d0a729499b56940e76cc1ae33e838602a52a3667f24ca9c0aeb1baeaccd972cc46eeb2d1f3755c6ed950c998c7d5",
"message_count": 14,
"disclosed_indices": [
4,
8,
9,
10
],
"disclosed": [
{
"index": 4,
"field": "id",
"encoding": "S",
"bytes_hex": "77662d6368696f2d726566756e642d303031"
},
{
"index": 8,
"field": "session_id",
"encoding": "Opt<S>",
"bytes_hex": "736573732d6368696f2d726566756e64"
},
{
"index": 9,
"field": "skill_id",
"encoding": "S",
"bytes_hex": "726566756e642d756e64657277726974696e67"
},
{
"index": 10,
"field": "skill_version",
"encoding": "S",
"bytes_hex": "302e312e30"
}
],
"proof_nonce_hex": "34373538613933663264626331363235363130356365366434376134656664643339643437373733336463623261356538303037356133653161616464636365",
"proof_bytes_hex": "816101387daa8084da8e7c7f08bf66252271a3f771419726d913b499ca40edea1c246d86fd676ac57697269fc9c970deadd8a9d51786475a7d82a1e8f3b4d146b24f4d17f8039fceab57aef8a4c2f18d8f81e267edc2b14adff08264aaa71a2a8e990cb3a4098e8701bf49603ba6018aaf629e55d0022446b0186d89519772c4aeaa52662c09f28e810fddd4fc09f27822624f5f95b2f775400c62fbdacb469aff1c06de7e5658cd9e453c3bbee96622643fe3357bf0fca3eaa88188885e2438565f1022b61bfa299ca0a96bddfa8c214497e492108a85f7377aab2bf03e4e7b95cbdc45526adcfa06cee2b5efbe5e174ffb690bbeade070fbf91419d4be5a8699079f26963253255adf990f5b3033ad3e58f5d34cb70f60746a554f81c5e40293c9c46c7c868f76bcf7ae824d9c1f7a291ff2ac6542e9304113eeda01f34c97bdf45055b55e1e599a31f80ac959ef2536f29ae76b32fdb81c066a33e009efdef7a56d67c8c985f5ae5092980f40f3a369d12e2f816b138cce34ca8f2c1e5c6f2a9cb3376e9998720967e51606e41051289556a7a251c915b0ad79b9f52713e97a5435f723b798964553bead8888bac602e1c3eed480589b3738c003e015e9822d935ab30e529996ab3b7f002252ae3267aac46f1e78d560b6e772fe4e1a60f866da43e6353750195eadcc6db1d173035d7d39f00ba7ba095663e106ebb82aaf5e7d1ef14f784f76895ac3697a82b9f811f5e9c9e31473cf73e61b7c058bb6f0df6d42d1334a53bdf93c8099fe5f63030a91196a429f99b1b5bed6d8e965b01b8aaf1ccfec40755d935e5a93243b9c76"
}bytes_hex is the disclosed message under the encoding its row declares, so a verifier reads the value back rather than taking the prover’s word for it: 726566756e642d756e64657277726974696e67 at index 9 is the ASCII of refund-underwriting, the skill the workflow ran. The subject_sha256_hex is the digest of the canonical workflow receipt body, which is the same value the package’s workflowIntersection carries as aggregateWorkflowReceiptSha256, so the proof and the intersection name one workflow receipt.
The slot manifest, and what it does not do
A chio.bbs-projection.manifest.v2 makes the slot table explicit so a verifier can check a proof against a declared policy instead of against a hard-coded table. Each slot names its field, encoding, message class, sensitivity class and whether it may be disclosed (projection_manifest.rs:14-27). A proof that opens a slot marked wholesale_only, or one marked Hidden, or one whose field or encoding disagrees with the proof, is refused (lib.rs:738-760). One field is refused unconditionally: duration_ms cannot be marked disclosed at all, because exact timing is a side channel (lib.rs:647-649, enforced at :708-717).
The manifest also carries a hidden-predicate list. It is a declaration, not a verification:
/// One hidden predicate declared by a BBS projection manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BbsProjectionHiddenPredicate {
pub predicate_id: String,
pub field: String,
pub operator: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_sha256: Option<String>,
}Every field is a string or a digest. There is no commitment, no challenge, no proof bytes, and the crate’s only checks over the list are that each entry’s id, field and operator are non-empty and that no id repeats (lib.rs:720-736). The lineage layer adds a policy check over the same list, refusing a capsule whose predicate id appears in a privacy profile’s forbidden_hidden_predicates or is absent from its allowed_hidden_predicates, and refusing more entries than the profile’s leakage_budget.max_hidden_predicates (chio-disclosure-lineage/src/verifier.rs:938, 972-994). That governs which predicate ids may be named. Nothing anywhere verifies that the named field satisfies the named operator. A verifier that treats a hidden_predicates entry as a proven statement is trusting the prover, which is the one thing the BBS signature exists to avoid. That is the same boundary the claim block enforces at the package layer, restated at the manifest layer.
Three motivating cases
The disclosure spec opens on three use cases and observes that they are structurally identical (spec/CHIO_SELECTIVE_DISCLOSURE.md:28-35): a cybersec peer proving a detection meets a confidence threshold without revealing the indicator, a finance counterparty proving a settlement falls within an amount cap without disclosing amount or counterparty, and a compliance verifier proving a KYC tier sits at or above a floor without revealing the tier or the evidence. All three reduce to one requirement: a verifier wants a predicate over a signed receipt body without learning the body.
Two of the three need a hidden comparison, and a hidden comparison is what the shipped slice does not do. What it does instead is narrow the set: an auditor who may see a KYC tier verbatim but not a customer record gets the tier as an opened message and the record as an unopened one, under a single signature that proves both were committed together. That is weaker than a range proof and stronger than sending a hand-redacted copy, because the verifier checks the reveal set against the issuer key rather than against the prover’s good manners.
Why alphabetical ordering
The projection order is alphabetical by serde field name, frozen behind the projection version. The alternative was a schema-declared order in a sidecar manifest. Alphabetical wins three ways. Inserting a field forces a new projection version rather than silently shifting indices, since the version constant is what a verifier compares. It is reproducible from the struct with nothing else in hand. And RFC 8785 already sorts object keys for canonical JSON, so the projection walks the body in the order the authoritative signature already committed to. The cost is evolution friction: renaming a field rebuilds the table.
The pheromone spec reserves a BBS projection over the deposit body and hands the ordering question to this one: the disclosure spec owns the bbs_messages() projection ordering, the disclosure envelope schema, and the verification algorithm (spec/CHIO_PHEROMONE.md:584-586). There is no divergence to reconcile. The pheromone side reserves the field name bbs_v01_messages and says it tracks whatever identifier the disclosure spec freezes (:587-591).
Reading a proof's size
The proof carries no message count of its own that a verifier can trust, so the verifier derives one from the byte length. Under this ciphersuite a proof is three BLS12-381 G1 points at forty-eight bytes plus four scalars at thirty-two, a fixed 272 bytes, and then one further scalar per undisclosed message:
const BBS_SHA256_POINT_BYTES: usize = 48;
#[cfg(feature = "bbs")]
const BBS_SHA256_SCALAR_BYTES: usize = 32;
#[cfg(feature = "bbs")]
const BBS_SHA256_PROOF_FIXED_BYTES: usize =
(3 * BBS_SHA256_POINT_BYTES) + (4 * BBS_SHA256_SCALAR_BYTES);message_count_from_bbs_sha256_proof (lib.rs:882-894) refuses a proof shorter than the fixed part, refuses one whose remainder is not a whole number of scalars, and otherwise returns disclosed_count + remainder / 32. The fixture’s proof_bytes_hex is 592 bytes: 272 fixed plus 320, which is ten scalars, which with its four disclosed messages gives the fourteen its message_count declares. A prover who padded the reveal set would have to produce a proof of a different length to match.
The practical consequence for whoever designs a projection table: hiding is what costs bytes, not disclosing. A table that folds many small fields behind one H message is cheaper to prove over and coarser to disclose from, which is the trade the wholesale-only column records.