idiolect
idiolect is a federated runtime for cross-schema interoperability on
ATProto. It uses
panproto as its schema and lens
substrate. Records are signed and content-addressed; translations
between records are lenses with formal get / put laws; and the
sociotechnical layer above the lenses (recommendations, beliefs,
verifications, deliberations) is itself a small set of ATProto
lexicons (dev.idiolect.*).
The name comes from linguistics:
- An idiolect is one party's choice of schemas, lenses, and conventions.
- A dialect is the bundle of idiolects a community treats as canonical.
- A language is the federated substrate over which idiolects and dialects meet, disagree, and slowly converge without a central arbiter.
This documentation covers the runtime; not the reasons it exists. For the underlying theory, see the project README and the deliberation lexicons.
Where to start
The documentation follows the Diátaxis structure:
- The Tutorial walks through one example end to end: install, fetch a record, validate, apply a lens, run a verification, publish a recommendation. Read this first if you have not used idiolect before.
- The Guides are task-oriented. Each guide answers a question of the form "how do I do X?". Reach for these when you know what you want to accomplish.
- The Concepts explain the underlying model:
the idiolect-dialect-language frame, the
dev.idiolect.*lexicon family, lens semantics, the vocabulary knowledge graph, the observer protocol, and the lexicon-evolution policy. Read these when you want to understand why something is the way it is. - The Reference is the per-symbol detail: one page per crate, one page per lexicon, the CLI surface, the HTTP query API, and the stability policy.
Architecture
flowchart TB
subgraph sources["Source of truth"]
LEX["lexicons/dev/idiolect/*.json"]
SPEC["*-spec/ (orchestrator, observer, verify, cli)"]
end
subgraph codegen["Codegen"]
CG["idiolect-codegen<br/>emit · check · check-compat"]
end
subgraph emitted["Emitted surfaces"]
RECS["idiolect-records (Rust)"]
NPM["@idiolect-dev/schema (TS)"]
end
subgraph runtime["Runtime"]
PDS[("ATProto PDS<br/>+ firehose")]
IDX["idiolect-indexer"]
ORC["idiolect-orchestrator"]
OBS["idiolect-observer"]
VER["idiolect-verify"]
MIG["idiolect-migrate"]
LENS["idiolect-lens"]
end
LEX --> CG
SPEC --> CG
CG --> RECS
CG --> NPM
PDS -->|commits| IDX
IDX --> ORC
IDX --> OBS
OBS -->|observation records| PDS
LENS -->|reads/writes records| PDS
LENS --> MIG
LENS --> VER
Lexicons under lexicons/dev/idiolect/ are the single source of
truth. Rust types and TypeScript validators are derived from
them. Three downstream crates carry a taxonomy of
similarly-shaped items (the orchestrator's queries, the
observer's methods, the verifier's runners). Each lives behind a
declarative JSON spec in <crate>-spec/; codegen emits the
wire-up, including the CLI dispatcher that fronts the
orchestrator's queries.
Stability
idiolect is pre-1.0. Releases in the 0.x series may include
arbitrary breaking changes between minor versions. Pin to an exact
version if you depend on this project, and read the
changelog
before bumping. See Stability and versioning.
Tutorial
This tutorial walks one example end to end. By the time you finish you will have:
- installed the
idiolectCLI and theidiolect-records/idiolect-lenscrates, - fetched a real ATProto record and validated it against the shipped lexicon,
- resolved a
dev.panproto.schema.lensrecord and applied it to translate a source record into a target record, - run a verification runner against a lens and recorded the result,
- published a
dev.idiolect.recommendationrecord from your own PDS endorsing a lens path.
The chapters are linear. Each one starts from the state the previous one left behind. If you want a task-shaped reference instead, jump to the Guides. If you want the underlying theory, the Concepts section is the place.
Prerequisites
You need:
- Rust 1.95 or newer (
rustup default stable). - Cargo, with network access to crates.io.
- A working ATProto identity (a
did:plc:*ordid:web:*) for the publishing chapter. If you do not have one, the bsky.app sign-up flow gives you one. Most chapters work without it. - About 30 minutes of focused time.
The rest of this tutorial assumes you are running commands from a fresh terminal at the root of a Cargo workspace you control.
Install and resolve a record
The fastest way to get a working idiolect setup is to install the CLI from source. The CLI links against the same library crates an application would, so installing it also gives you the runtime machinery for later chapters.
Install the CLI
git clone https://github.com/idiolect-dev/idiolect
cd idiolect
cargo install --path crates/idiolect-cli
This compiles every crate the CLI depends on (idiolect-records,
idiolect-identity, idiolect-lens, plus their atproto transport
features) and drops an idiolect binary into ~/.cargo/bin. The
build takes two to four minutes on a recent laptop. There is no
runtime dependency on the cloned tree after install; you can cd
anywhere.
Confirm it works:
idiolect version
idiolect 0.8.0
Resolve a DID
The first thing the runtime does on any record fetch is resolve a
DID to its PDS. idiolect resolve exposes that step on its own:
The project's own DID is a good first target:
idiolect resolve did:plc:wdl4nnvxxdy4mc5vddxlm6f3
{
"did": "did:plc:wdl4nnvxxdy4mc5vddxlm6f3",
"method": "Plc",
"handle": "idiolect.dev",
"pds_url": "https://jellybaby.us-east.host.bsky.network",
"also_known_as": ["at://idiolect.dev"]
}
The resolver uses
idiolect-identity's
ReqwestIdentityResolver. For did:plc:* it goes through
plc.directory; for did:web:* it fetches
https://<host>/.well-known/did.json. Both transports are built
on reqwest.
If the DID does not resolve, the CLI prints a structured error
on stderr (the message is IdentityError-shaped: the variant
plus the underlying transport message).
Fetch a record
idiolect fetch takes an at-uri and returns the raw record body
(the value field of the xrpc response, not the response
envelope). The project DID has a tutorial lens record published;
fetching it exercises the runtime path end to end:
idiolect fetch \
at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text
The response is a dev.panproto.schema.lens body carrying the
protolens chain (blob), the source and target schema at-uris,
the optic class (iso), and the content hash. Chapter 3 takes
this exact record and runs it through apply_lens.
The fetch goes through the same PdsClient impl apply_lens
uses, so any record you can fetch this way you can also feed
into the lens runtime.
Where things are stored
| Artifact | Location |
|---|---|
The idiolect binary | ~/.cargo/bin/idiolect |
| Cached crate sources | ~/.cargo/registry/src/ |
| Cached PLC responses | ~/.cache/idiolect/plc/ (only if you set IDIOLECT_CACHE_DIR) |
You do not need to wire any of this up by hand. The next chapter
takes the raw json idiolect fetch returned and validates it
against the shipped lexicon.
Validate against the lexicon
Every dev.idiolect.* lexicon ships in two forms: the JSON document
under lexicons/dev/idiolect/ and a generated Rust type under
idiolect_records::generated.
The generated type carries serde codecs that fail closed: a record
that does not match the lexicon will not deserialize.
Add the records crate
cargo new --lib idiolect-tutorial
cd idiolect-tutorial
cargo add idiolect-records anyhow serde_json tokio --features tokio/macros,tokio/rt
idiolect-records is a small crate (no transport dependencies). It
exports one Rust type per record kind plus the
Record trait.
Decode a record by NSID
The crate ships minimally-valid fixtures for every record kind
under idiolect_records::examples. They are real records: same
shape the codec emits for a wire-format publish, same
constraints, same Record impl. Use them to exercise the
decode path without depending on what's on the network.
In src/main.rs:
use idiolect_records::{decode_record, examples, AnyRecord, Dialect, Record}; fn main() -> anyhow::Result<()> { // The fixture is a typed value; serialise it back to JSON // and decode through the dispatcher to exercise the same // path a firehose handler would take. let dialect = examples::dialect(); let value = serde_json::to_value(&dialect)?; let rec = decode_record(&Dialect::nsid(), value)?; match rec { AnyRecord::Dialect(d) => println!( "{}: {} idiolects, {} preferred lenses", d.name, d.idiolects.as_ref().map_or(0, |v| v.len()), d.preferred_lenses.as_ref().map_or(0, |v| v.len()), ), other => anyhow::bail!( "expected a dialect, got {}", other.nsid_str(), ), } Ok(()) }
Run it:
cargo run
ud-en-2026: 0 idiolects, 0 preferred lenses
decode_record is the dispatch primitive: it takes an NSID and
a JSON value, looks up the matching Record impl, and hands
back an AnyRecord. If the JSON does not match the schema, it
returns an error pointing at the first invalid field.
The same path works for a JSON file fetched from a PDS, once
some party publishes a dev.idiolect.dialect record on the
network: read the bytes from disk (or from the
com.atproto.repo.getRecord body), parse as
serde_json::Value, hand to decode_record. The fixture
shortcut is just a way to make this chapter not depend on
network state.
Validation is structural, not just type-shaped
The generated codecs validate every constraint declared in the lexicon, not just the field types:
maxLengthandmaxGraphemeson strings.formatconstraints (at-uri,did,nsid,datetime,language,cid-link).knownValuesfor open enums (the value is preserved verbatim, but the codec records whether it was a known slug or fell through toOther(String)so consumers can decide).requiredarrays.uniondiscriminator tags via$type.
A record that violates one of these surfaces as a deserialization
error with a serde_path_to_error-shaped path:
Error: dialect.entries[0].nsid: invalid format `at-uri`: missing scheme
The boundary is exactly where you want it: at the parse, before any business logic touches the value.
Map of the family
The generated tree mirrors the lexicons one-to-one. As of v0.8.0:
| Record | Module | NSID |
|---|---|---|
Adapter | adapter | dev.idiolect.adapter |
Belief | belief | dev.idiolect.belief |
Bounty | bounty | dev.idiolect.bounty |
Community | community | dev.idiolect.community |
Correction | correction | dev.idiolect.correction |
Deliberation | deliberation | dev.idiolect.deliberation |
DeliberationStatement | deliberation_statement | dev.idiolect.deliberationStatement |
DeliberationVote | deliberation_vote | dev.idiolect.deliberationVote |
DeliberationOutcome | deliberation_outcome | dev.idiolect.deliberationOutcome |
Dialect | dialect | dev.idiolect.dialect |
Encounter | encounter | dev.idiolect.encounter |
Observation | observation | dev.idiolect.observation |
Recommendation | recommendation | dev.idiolect.recommendation |
Retrospection | retrospection | dev.idiolect.retrospection |
Verification | verification | dev.idiolect.verification |
Vocab | vocab | dev.idiolect.vocab |
Fixtures are exported under idiolect_records::examples::* for
every record kind except the four deliberation lexicons, which
are recent additions still awaiting bundled fixtures. The
shipped fixtures are minimally-valid: every required field is
present and the codec round-trip is a no-op.
Reusable trait surface
Anything you write that consumes records can be generic over the
Record trait:
#![allow(unused)] fn main() { use idiolect_records::Record; fn describe<R: Record>() -> String { format!("{} record (NSID: {})", R::kind(), R::NSID) } }
R::kind() returns the short kind name ("encounter",
"recommendation", ...) and R::NSID is the fully-qualified
NSID constant. Record does not carry instance-level methods on
the record body itself; the at-uri at which a record lives is
external (a (did, collection, rkey) triple from the firehose
or PDS response).
The same trait is what the indexer (chapter on indexing a firehose) uses to filter out-of-family commits before decode.
The next chapter takes a record we trust and runs it through a panproto lens.
Apply a lens
A lens is a structure-preserving translation between two schemas.
On the wire it is a dev.panproto.schema.lens record (re-exported
by idiolect-records as PanprotoLens); at runtime it is a
panproto_lens::Lens instantiated against a Schema graph.
idiolect-lens bridges the
two: it resolves a lens record by at-uri, loads both schemas, and
runs the lens.
What a lens does
A lens has a forward direction and a backward direction:
get translates a source record A into a target view B plus a
complement (the data that the projection
discarded). put reconstructs A from a (possibly modified) B
and the complement. The two directions obey the GetPut and PutGet
laws covered in Lens semantics and laws.
Wire it up
idiolect-lens is publish = false; depend on it via git (or a
path, when working inside the workspace):
# in Cargo.toml
idiolect-lens = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0", features = ["pds-reqwest"] }
panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
tokio = { version = "1", features = ["full"] }
The lens runtime needs three pieces: a Resolver (fetches the
lens record by at-uri), a SchemaLoader (turns the
dev.panproto.schema.schema at-uris on the lens record into
typed panproto_schema::Schema values), and a Protocol. v0.9
ships PdsSchemaLoader to pair with PdsResolver; both share a
ReqwestPdsClient.
src/main.rs:
use idiolect_lens::{ apply_lens, ApplyLensInput, AtUri, PdsResolver, PdsSchemaLoader, ReqwestPdsClient, }; use panproto_schema::Protocol; const PDS: &str = "https://jellybaby.us-east.host.bsky.network"; const LENS: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text"; #[tokio::main] async fn main() -> anyhow::Result<()> { let client = ReqwestPdsClient::with_service_url(PDS); let resolver = PdsResolver::new(client.clone()); let loader = PdsSchemaLoader::new(client); let protocol = Protocol::default(); let lens_uri = AtUri::parse(LENS)?; let source_record: serde_json::Value = serde_json::from_str(r#"{ "text": "hello, world" }"#)?; let out = apply_lens(&resolver, &loader, &protocol, ApplyLensInput { lens_uri, source_record, source_root_vertex: None, }).await?; println!("{}", serde_json::to_string_pretty(&out.target_record)?); Ok(()) }
Run it:
cargo run
{
"text": "hello, world"
}
The lens referenced above is real. The project DID has the three records the runtime needs to resolve it published on its PDS:
dev.panproto.schema.schema/tutorial-post-body-v1— a single-field "post:body" record with a stringtextchild.dev.panproto.schema.schema/tutorial-post-body-v2— the same shape with the kind relabelled totext.dev.panproto.schema.lens/tutorial-rename-sort-string-to-text— a single-steprename_sortchain. The optic class isIso; round-trip is byte-equal.
apply_lens is one async call. It does five things in order:
- Resolve the lens record from the PDS via
PdsResolver. - Load the source and target schemas from the schema loader.
- Instantiate the protolens (or protolens chain) against the source schema under the given protocol.
- Parse
source_recordinto a panproto w-type instance, project it throughget, and serialize the view back to JSON under the target schema. - Return the target record together with the complement.
The complement is a typed panproto_lens::Complement. Treat it
as an opaque token: store it next to the target view, hand it
back to apply_lens_put when you want to run the reverse
direction, do not edit it.
Reverse the direction
#![allow(unused)] fn main() { use idiolect_lens::{apply_lens_put, ApplyLensPutInput}; let back = apply_lens_put( &resolver, &loader, &protocol, ApplyLensPutInput { lens_uri: lens_uri.clone(), target_record: out.target_record, complement: out.complement, target_root_vertex: None, }, ) .await?; assert_eq!(back.source_record, source_record); }
If the lens is an isomorphism, put(get(a)) returns the original
a byte-for-byte. If it is a projection (information was dropped on
the way through), put reconstructs a from the target plus the
complement. If you modify the target between the calls, put
applies the modification on top of the original source.
What can go wrong
| Symptom | Cause |
|---|---|
LensError::NotFound | The lens at-uri did not resolve. Check the DID and the rkey. |
LensError::LexiconParse | The schema loader returned bytes that were not a valid panproto schema. |
LensError::Translate | The source record did not parse as an instance of the source schema. |
| Output complement is huge | The lens is closer to a projection than you thought. See Lens semantics. |
The runtime is Send-clean. You can hold an Arc<dyn Resolver> and
call apply_lens from inside an #[async_trait] handler in an
HTTP server.
The next chapter takes that lens and runs a verification against it.
Run a verification
A dev.idiolect.verification record is a signed claim that a lens
satisfies a property. The verifier crate
(idiolect-verify)
ships four runner kinds:
roundtrip-testrunsput(get(a)) == aover a corpus of source records.property-testruns an arbitrary boolean predicate over a corpus, suitable for laws beyond round-trip (idempotence, commutativity, naturality, ...).static-checkruns panproto's existence and structural checks against the lens chain.coercion-lawruns panproto's sample-based coercion-law checker against anyCoerceTypestep.
Each ships as a struct implementing VerificationRunner. The
crate is library-only; runners are invoked programmatically.
Wire up a runner
# in Cargo.toml
idiolect-verify = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0" }
idiolect-lens = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0", features = ["pds-reqwest"] }
idiolect-records = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0" }
panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.47.0" }
tokio = { version = "1", features = ["full"] }
src/main.rs:
use idiolect_lens::{PdsResolver, PdsSchemaLoader, ReqwestPdsClient}; use idiolect_records::Datetime; use idiolect_records::generated::dev::idiolect::defs::LensRef; use idiolect_verify::{ RoundtripTestRunner, VerificationRunner, VerificationTarget, }; use panproto_schema::Protocol; const PDS: &str = "https://jellybaby.us-east.host.bsky.network"; const LENS_URI: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text"; #[tokio::main] async fn main() -> anyhow::Result<()> { let client = ReqwestPdsClient::with_service_url(PDS); let resolver = PdsResolver::new(client.clone()); let loader = PdsSchemaLoader::new(client); // Small corpus matching the lens's source schema (single // `text` string child on a `post:body` object). let corpus = vec![ serde_json::json!({ "text": "hello, world" }), serde_json::json!({ "text": "" }), serde_json::json!({ "text": "líneas con tildes y emoji 🦀" }), ]; let runner = RoundtripTestRunner::new(resolver, loader, Protocol::default(), corpus); let target = VerificationTarget { lens: LensRef { uri: Some(LENS_URI.parse()?), cid: None, direction: None, }, verifier: "did:plc:wdl4nnvxxdy4mc5vddxlm6f3".parse()?, occurred_at: Datetime::parse("2026-05-12T00:00:00.000Z")?, tool_override: None, }; let verification = runner.run(&target).await?; println!("result = {:?}", verification.result); println!("kind = {:?}", verification.kind); println!("tool = {} {}", verification.tool.name, verification.tool.version); Ok(()) }
cargo run
result = Holds
kind = RoundtripTest
tool = idiolect-verify/roundtrip-test 0.9.0
The runner walked the corpus, applied the rename-sort lens
forward then backward, and confirmed every record round-tripped
byte-for-byte. A single counterexample would have produced
result = Falsified.
Falsified is a record, not an error
A falsified property returns
Ok(Verification { result: Falsified, ... }), not an error. The
substrate's view: a falsified verification is the signal the
community is paying the runner to produce. Treat it like a
finding, sign and publish it. Consumers reading the falsified
record decide whether to continue invoking the lens.
VerifyError is reserved for input-shape, transport, or
irrecoverable-state failures (a corpus the runner could not load,
a schema the loader could not resolve). Those are operator
problems; the lens's actual behaviour is captured in the
returned record.
Publish the result
The verification value the runner returned is a typed
Verification record ready to publish. The publishing path
goes through idiolect_lens::RecordPublisher; see chapter 5
on publishing for the wire-up.
Reading verifications back
The orchestrator's HTTP API exposes
GET /v1/verifications?lens_uri=... for "every verification on
this lens":
idiolect orchestrator verifications \
--lens_uri at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text
The orchestrator reads its catalog (populated by the indexer) and returns the matching verification records. This is the shape downstream consumers use to decide whether to invoke a lens: query the verifier registry, accept the verifications they trust, reject the rest, and proceed only if the surviving set covers the properties their use case requires.
From the CLI
The library code above is also exposed as a CLI subcommand for quick operator runs. All four shipped runner kinds are wrapped:
idiolect verify roundtrip-test --lens at://.../tutorial-rename-sort-string-to-text
idiolect verify property-test --lens at://.../tutorial-rename-sort-string-to-text --corpus ./samples.jsonl
idiolect verify static-check --lens at://.../tutorial-rename-sort-string-to-text
idiolect verify coercion-law --lens at://... --vcs-url https://vcs.example --standard atproto-lexicon
The CLI prints the typed Verification record as JSON and
exits non-zero on Falsified or Inconclusive, which makes it
suitable for CI gates.
Additional kinds in the lexicon's verification.kind enum
(formal-proof, conformance-test, convergence-preserving)
are recognised slugs awaiting community-contributed runners;
authoring one is the
Author a verification runner loop.
The next chapter publishes a dev.idiolect.recommendation that
endorses a lens path under specific applicability conditions.
Publish a recommendation
A dev.idiolect.recommendation is a community-published opinion
that a particular lens path is appropriate under specific
applicability conditions. It is the unit consumers query before
choosing a translation: a lens that nobody recommends is just
code; a lens that a community recommends under conditions a
consumer satisfies is a routing decision.
The shape is:
issuingCommunity : at-uri # the community publishing
conditions : [Condition] # postfix applicability tree
preconditions : [Condition] # additional assumptions
lensPath : [at-uri] # one or more chained lenses
caveats : [Caveat] # structured failure modes
requiredVerifications : [LensProperty]
occurredAt : datetime
conditions and preconditions are postfix-operator trees over
the combinator set defined inside
dev.idiolect.recommendation.
Author the record
idiolect-records = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0" }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
#![allow(unused)] fn main() { use idiolect_records::generated::dev::idiolect::defs::{LensRef, SchemaRef}; use idiolect_records::generated::dev::idiolect::recommendation::{ ConditionSourceIs, Recommendation, RecommendationConditions, }; use idiolect_records::{Datetime, Record}; const LENS_URI: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text"; const SRC_SCHEMA_URI: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.schema/tutorial-post-body-v1"; fn build_recommendation(community_did: &str) -> anyhow::Result<Recommendation> { let community = format!("at://{community_did}/dev.idiolect.community/canonical"); Ok(Recommendation { issuing_community: community.parse()?, conditions: vec![RecommendationConditions::ConditionSourceIs( ConditionSourceIs { schema: SchemaRef { cid: None, language: None, uri: Some(SRC_SCHEMA_URI.parse()?), }, }, )], preconditions: None, lens_path: vec![LensRef { uri: Some(LENS_URI.parse()?), cid: None, direction: None, }], caveats: None, caveats_text: None, annotations: Some( "Endorses the rename-sort tutorial lens for source \ records matching the v1 tutorial post-body schema." .to_owned(), ), required_verifications: None, basis: None, occurred_at: Datetime::parse("2026-05-12T00:00:00.000Z")?, supersedes: None, }) } }
Every required field is type-checked at construction. Optional
fields are Option<...>. The open-enum slugs in Condition*
variants round-trip through their *Vocab siblings as covered
in Open enums and vocabularies.
Get an authenticated session
The simplest auth path is an app password (legacy Bearer mode, not OAuth + DPoP). Generate one at https://bsky.app/settings/app-passwords for the account you want to publish under, then load it via env vars:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize)] struct CreateSessionRequest<'a> { identifier: &'a str, password: &'a str, } #[derive(Deserialize)] struct Session { did: String, #[serde(rename = "accessJwt")] access_jwt: String, } async fn create_session( http: &reqwest::Client, pds: &str, handle: &str, password: &str, ) -> anyhow::Result<Session> { let resp = http .post(format!("{pds}/xrpc/com.atproto.server.createSession")) .json(&CreateSessionRequest { identifier: handle, password }) .send().await?; if !resp.status().is_success() { anyhow::bail!("createSession {}: {}", resp.status(), resp.text().await?); } Ok(resp.json().await?) } }
OAuth + DPoP is the recommended path for production. The
machinery lives in idiolect-lens under the dpop-p256 feature
plus idiolect-oauth's session stores. For tutorial purposes
the Bearer path is enough.
Sign and publish
The PDS accepts a typed record body plus a $type discriminator
on a com.atproto.repo.createRecord call:
#[derive(Serialize)] struct CreateRecordRequest<'a> { repo: &'a str, collection: &'a str, rkey: &'a str, record: serde_json::Value, } async fn publish( http: &reqwest::Client, pds: &str, bearer: &str, repo: &str, rec: &Recommendation, rkey: &str, ) -> anyhow::Result<()> { let mut value = serde_json::to_value(rec)?; if let serde_json::Value::Object(ref mut map) = value { map.insert( "$type".to_owned(), serde_json::Value::String(Recommendation::NSID.to_owned()), ); } let resp = http .post(format!("{pds}/xrpc/com.atproto.repo.createRecord")) .bearer_auth(bearer) .json(&CreateRecordRequest { repo, collection: Recommendation::NSID, rkey, record: value, }) .send().await?; if !resp.status().is_success() { anyhow::bail!("createRecord {}: {}", resp.status(), resp.text().await?); } Ok(()) } #[tokio::main] async fn main() -> anyhow::Result<()> { let pds = std::env::var("PDS_URL")?; let handle = std::env::var("ATPROTO_HANDLE")?; let password = std::env::var("ATPROTO_PASSWORD")?; let http = reqwest::Client::new(); let sess = create_session(&http, &pds, &handle, &password).await?; let rec = build_recommendation(&sess.did)?; publish(&http, &pds, &sess.access_jwt, &sess.did, &rec, "tutorial-rename-sort").await?; println!("published at://{}/{}/tutorial-rename-sort", sess.did, Recommendation::NSID); Ok(()) }
Run with credentials in the env:
PDS_URL=https://bsky.social \
ATPROTO_HANDLE=yourhandle.bsky.social \
ATPROTO_PASSWORD='xxxx-xxxx-xxxx-xxxx' \
cargo run
The project DID has already published this exact record:
idiolect fetch \
at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.idiolect.recommendation/tutorial-rename-sort
If the record decodes and the issuingCommunity resolves to a
community you control, the loop is closed:
flowchart LR
A[author] -->|publishes| R[recommendation]
R -->|points at| L[lens path]
L -->|verified by| V[verification records]
R -->|consumed by| C[consumer]
C -->|decides| I[invoke or not]
The community has expressed an opinion. The lens has
machine-checkable verifications attached (chapter 4 produced
Holds for the roundtrip-test property). A consumer querying
the orchestrator can fetch both, evaluate the conditions, and
decide whether to invoke.
From the CLI
The CLI ships both pieces of this flow:
# 1. Authenticate (app password; legacy Bearer mode).
idiolect oauth login --handle yourhandle.bsky.social
# password from --app-password or env
# 2. Publish a record from a JSON file.
idiolect publish recommendation --record ./tutorial-rename-sort.json
idiolect oauth login exchanges credentials via
com.atproto.server.createSession and stores the resulting
session as a JSON file. idiolect publish <kind> validates the
JSON against the typed Record impl, splices in $type, and
POSTs com.atproto.repo.createRecord under the stored session.
ATProto is moving off app passwords toward OAuth + DPoP; the
next-iteration CLI wraps atrium-oauth's browser-handoff dance
and persists the resulting DPoP-bound session through the same
OAuthTokenStore. The publish path is unchanged.
That is the full loop. Where to go next:
- Run the orchestrator HTTP API shows how the consumer side of that flow is served.
- Author a community vocabulary covers the open-enum extension story.
- The Concepts section explains why this loop is shaped the way it is.
Guides
Each guide answers one question of the form "how do I do X?". They assume you have read the Tutorial (or are comfortable working without it) and have the runtime installed.
| Guide | When to reach for it |
|---|---|
| Index a firehose | You want to stream commits from a PDS firehose into your own indexer. |
| Run the orchestrator HTTP API | You want a read-only query surface over cataloged records. |
| Run the observer daemon | You want to fold encounter-family records into observation records. |
| Author a verification runner | You want to add a new property kind to the verifier. |
| Publish and resolve a lens | You have a panproto lens and want it on the network. |
| Migrate records across a revision | A schema you depend on changed; you want to lift records across the change. |
| Configure OAuth sessions | You want a session store the publishing path can use. |
| Run codegen | You edited a lexicon or a spec and need the generated tree refreshed. |
| Author a community vocabulary | You want to extend an open enum or publish a typed knowledge graph. |
| Bundle records into a dialect | You want to ship a coherent set of idiolects as one canonical bundle. |
Index a firehose
idiolect-indexer is
a firehose consumer factored into three trait surfaces:
EventStream: yieldsRawEvents from a PDS firehose. Shipped impls:JetstreamEventStream(Jetstream websocket feed) andTappedFirehoseStream(the at-proto-native firehose viatapped).RecordHandler<F: RecordFamily = IdiolectFamily>: handles one decodedIndexerEvent<F>. The family parameter narrows the handler to the records the indexer should not skip; everything outside the family is dropped before decode.CursorStore: persists the last-acknowledged sequence number per subscription so a restart resumes where the previous run left off.
drive_indexer composes the three. drive_idiolect_indexer is
the convenience alias when the family is IdiolectFamily.
Minimum viable indexer
use idiolect_indexer::{ drive_idiolect_indexer, FilesystemCursorStore, IndexerConfig, IndexerEvent, JetstreamEventStream, RecordHandler, }; use idiolect_records::IdiolectFamily; struct PrintHandler; #[async_trait::async_trait] impl RecordHandler<IdiolectFamily> for PrintHandler { async fn handle( &self, event: &IndexerEvent<IdiolectFamily>, ) -> Result<(), idiolect_indexer::IndexerError> { println!("{} {} {:?}", event.did, event.collection, event.action); Ok(()) } } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut stream = JetstreamEventStream::connect("wss://...").await?; let cursors = FilesystemCursorStore::open("./cursor.json")?; let handler = PrintHandler; let config = IndexerConfig::default(); drive_idiolect_indexer(&mut stream, &handler, &cursors, &config).await?; Ok(()) }
Add features:
cargo add idiolect-indexer \
--features firehose-jetstream,cursor-filesystem,reconnecting
reconnecting wraps the inner stream in an exponential-backoff
loop. cursor-sqlite swaps the filesystem cursor store for a
SQLite-backed one. resilience adds retry and circuit-breaker
handler wrappers.
Family-typed dispatch
The IdiolectFamily parameter is a typed predicate over NSIDs.
A commit whose collection is not in the family drops before
decode, so an upstream PDS adding a record type ahead of your
codegen run does not halt the loop. To handle two families
(idiolect plus a downstream community's lexicons), compose:
#![allow(unused)] fn main() { use idiolect_records::{IdiolectFamily, OrFamily}; struct MyFamily; // `MyFamily` impls `RecordFamily`. let handler: MyHandler<OrFamily<IdiolectFamily, MyFamily>> = ...; }
OrFamily<F1, F2> recognises every NSID either side claims. Its
AnyRecord is OrAny, a tagged union over the two halves.
detect_or_family_overlap audits a probe set at boot so a
configuration mistake does not silently shadow the right-side
family.
Cursor semantics
drive_indexer calls
CursorStore::commit(subscription_id, seq) after the handler
returns Ok. A handler that wants at-least-once semantics
should make its work idempotent before returning; a handler
that wants exactly-once semantics needs to coordinate the commit
with its own storage transaction.
Errors propagate as IndexerError. The variants distinguish
transport failures (Stream), decode failures (Decode),
family-contract bugs (FamilyContract, fired only when
contains returns true but decode returns None),
handler-defined errors (Handler), missing-body events
(MissingBody), and cursor-store failures (Cursor).
Observability
Every shipped surface logs through tracing. Wire a
subscriber:
#![allow(unused)] fn main() { tracing_subscriber::fmt() .with_env_filter("idiolect_indexer=info") .init(); }
You will see one log line per accepted commit, one per skipped commit (debug level), and one per cursor commit. The orchestrator exposes a Prometheus surface; see Run the orchestrator HTTP API.
Run the orchestrator HTTP API
idiolect-orchestrator
is a read-only HTTP query API over a record catalog. It pairs
with the indexer (which writes the catalog through
CatalogHandler) and exposes the result over a small set of
typed query endpoints.
What it serves
GET /healthz,GET /readyz— liveness and readiness.GET /metrics— Prometheus exposition.GET /v1/stats— record counts per kind.- One endpoint per declarative query under
orchestrator-spec/queries.json. Snapshot at v0.8.0: bounties (open, want-lens, by-requester), adapters (by framework, by invocation protocol, with verification), recommendations (starting from a source schema), verifications (by lens, by kind), communities (by member, by name), dialects (for community), beliefs (about a record, by holder), and vocabularies (with world, by name).
The full surface is in the HTTP query API reference.
Run it
The shipped daemon binary lives behind the daemon feature:
cargo install --path crates/idiolect-orchestrator \
--features daemon
The daemon feature pulls in catalog-sqlite, query-http,
the indexer's tapped firehose, and the SQLite cursor store. Run:
idiolect-orchestrator \
--catalog ./catalog.sqlite \
--bind 0.0.0.0:8787
The exact CLI surface is documented by the daemon's --help.
The catalog is populated by the indexer that ships with the
daemon; the orchestrator reads from the same SQLite file.
Query it
curl -s http://localhost:8787/v1/stats | jq
curl -s 'http://localhost:8787/v1/bounties/open' | jq
curl -s 'http://localhost:8787/v1/adapters?framework=hasura' | jq
curl -s 'http://localhost:8787/v1/verifications?lens_uri=at://...' | jq
Every shipped query has a CLI subcommand under
idiolect orchestrator <subcommand> that calls the same
endpoint:
idiolect orchestrator bounties
idiolect orchestrator adapters --framework hasura
idiolect orchestrator verifications --lens_uri at://...
The CLI dispatcher (in
crates/idiolect-cli/src/generated.rs) is generated from the
same spec the HTTP routes are.
Add a query
Queries live in orchestrator-spec/queries.json (a single JSON
document with a top-level queries array). To add one:
- Add a new entry to the array. Each entry declares the query's name, description, parameters, predicate (a panproto-expr expression), and the record kind it iterates over.
- Run
cargo run -p idiolect-codegen. - The generated tree picks up the new query: HTTP route, query-string parser, response shape, and CLI subcommand.
The hand-written part is the panproto-expr predicate inside the spec entry; the generated tree handles routing, parameter parsing, and response encoding.
Observability
The orchestrator exposes /metrics in Prometheus exposition
format. Plus structured tracing logs. The exact metric names
and label sets are defined in
crates/idiolect-orchestrator/src/http.rs; see the source for
the live list.
Deployment
A pre-built container image ships at
ghcr.io/idiolect-dev/orchestrator:<version> per release. The
image is signed with sigstore keyless; verification policy is
in
docs/ci-cd.md.
Run the observer daemon
The observer reads encounter-family records from the firehose,
folds them through an ObservationMethod, and publishes a
dev.idiolect.observation record on each flush. Observations
are the durable summary; encounters are the event log.
The crate is
idiolect-observer.
Its daemon binary lives behind the daemon feature.
What it produces
A dev.idiolect.observation record carries:
- the observer's DID,
- a structured
methoddescriptor (name, version, optional parameters and code reference), - a
scopedescribing which records the aggregation covers, - the method's
outputpayload (shape is method-defined), - a signature.
The record kind is shared with deliberation tallies via the
deliberationOutcome lexicon, but that is a separate
record-kind, not an observation flavour.
Why the observer publishes records, not just metrics
Observations are content-addressed and signed, like any other ATProto record. A consumer reading an observation can:
- verify the signer DID,
- ask the indexer for the underlying encounters and re-fold them independently,
- treat the observation as a soft assertion of fact, not a single source of truth.
A central metrics endpoint cannot do that. The same record shape also lets multiple observers run in parallel and disagree.
Run the daemon
cargo install --path crates/idiolect-observer --features daemon
The shipped binary's exact CLI surface is documented by its
--help. It wires:
- A firehose stream (tapped, via the indexer's
firehose-tappedfeature, transitively pulled in bydaemon). - A SQLite cursor store.
- An
ObserverHandler<M, P>connecting anObservationMethodto anObservationPublisher. - A flush schedule that triggers observation publication.
Bundled methods
The spec at observer-spec/methods.json declares eight bundled
methods. Each lives in crates/idiolect-observer/src/methods/.
| Method | Folds |
|---|---|
correction-rate | Per-lens correction counts grouped by reason. |
encounter-throughput | Encounter traffic by kind and downstream result. |
verification-coverage | Per-lens verification counts by kind, result, and distinct verifiers. |
lens-adoption | Per-lens encounter count and distinct invokers. |
action-distribution | Encounter counts grouped by use.action, optionally rolled up through a vocab. |
purpose-distribution | Encounter counts grouped by use.purpose. |
basis-distribution | Record counts grouped by basis variant, bucketed by record kind. |
attribution-chains | dev.idiolect.belief counts by holder and subject. |
Methods come in two forms (declared in the spec): record-form
methods consume &IndexerEvent<IdiolectFamily> directly;
instance-form methods consume a panproto WInstance and wrap
into the record form via InstanceMethodAdapter.
default_methods() returns boxed instances of every
record-form method; instance-form methods need a caller-supplied
schema resolver and are constructed individually.
Add a method
Edit observer-spec/methods.json, add the method's entry, run
cargo run -p idiolect-codegen. The generated descriptor table
picks up the new method. Implement ObservationMethod (or
InstanceMethod) in
crates/idiolect-observer/src/methods/<module>.rs and add it
to the default_methods() constructor.
Operational notes
- Observers should run with their own DID, distinct from the DIDs whose encounters they observe.
- Multiple observers publishing observations of the same scope is expected and useful; consumers can require quorum among of trusted observers before treating an observation as authoritative.
Note on deliberation-tally
The shipped deliberation-tally method emits its
per-statement per-stance vote counts inside an
observation.output blob, not as a typed
dev.idiolect.deliberationOutcome record. The data shape is
the same; the surface differs. A variant that publishes the
typed outcome record directly is a small refactor on top of
the existing DeliberationTallyMethod.
Author a verification runner
A verification runner is a function that takes a lens (and a
small set of typed inputs) and returns a Verification record
with result set to Holds, Falsified, or Inconclusive.
The result record is publishable as a
dev.idiolect.verification; downstream consumers reading the
record can decide whether to trust it.
The runner trait:
#![allow(unused)] fn main() { pub trait VerificationRunner: Send + Sync { fn kind(&self) -> VerificationKind; fn tool(&self) -> Tool; async fn run(&self, target: &VerificationTarget) -> VerifyResult<Verification>; } }
A falsified property returns
Ok(Verification { result: Falsified, ... }), not an error.
Falsification is the signal the community is paying the runner
to produce; VerifyError is reserved for input-shape, transport,
or irrecoverable-state failures.
Shipped runners
Four kinds ship in crates/idiolect-verify/src/:
| Kind | Runner |
|---|---|
roundtrip-test | RoundtripTestRunner |
property-test | PropertyTestRunner |
static-check | StaticCheckRunner |
coercion-law | CoercionLawRunner |
The lexicon's verification.kind field is open-enum and lists
additional kinds (formal-proof, conformance-test,
convergence-preserving); those kinds are recognised but not
shipped as runners. Communities that need them author their own.
Add a runner kind
The project's spec-driven layout means adding a kind is two edits:
- Add an entry to
verify-spec/runners.jsondeclaring the kind and its description. Runcargo run -p idiolect-codegen; the generated kind taxonomy (crates/idiolect-verify/src/generated.rs) picks up the new kind. - Implement
VerificationRunnerfor a struct in a new module undercrates/idiolect-verify/src/. Re-export it fromlib.rs.
The runner's kind() returns the new VerificationKind
variant; the run method does the work and returns a
Verification record. Use build_verification (in
runner.rs) to package the result with the structured
property field.
Test
Every shipped runner has integration tests that exercise the
holds / falsified / inconclusive cases against fixtures. New
runners follow the same pattern: a fixture with a known result,
a call to runner.run(...), an assertion on the returned
Verification.
Publish a verification record
Once you have a Verification, publish it via
idiolect_lens::RecordPublisher:
#![allow(unused)] fn main() { use idiolect_lens::RecordPublisher; let publisher = RecordPublisher::new(writer, my_did); let resp = publisher.create(&verification).await?; println!("published: {}", resp.uri); }
The publisher serializes the record, splices the $type field,
and forwards to the configured PdsWriter. The signing path
goes through SigningPdsWriter plus a DpopProver from the
pds-reqwest and (optionally) dpop-p256 features.
CLI surface
The idiolect verify <kind> subcommand wraps each shipped
runner against a live PDS via PdsResolver + PdsSchemaLoader:
idiolect verify roundtrip-test --lens AT_URI [--corpus PATH]
idiolect verify property-test --lens AT_URI --corpus PATH [--budget N]
idiolect verify static-check --lens AT_URI
idiolect verify coercion-law --lens AT_URI --vcs-url URL --standard STD
Corpus files may be JSON arrays or JSON Lines. The
property-test generator cycles through the corpus by index;
--budget controls case count. The CLI prints the typed
Verification record as JSON and exits non-zero on Falsified
or Inconclusive. Publishing the result is a separate step;
pipe to idiolect publish verification --record -.
Publish and resolve a lens
A lens on the network is two artifacts:
- A
dev.panproto.schema.lensrecord on a PDS, carrying the protolens (or protolens chain) blob and pointers to the source and target schemas. - The two schemas themselves, each a
dev.panproto.schema.schemarecord (or agetSchemaxrpc response from a panproto vcs).
The lens record is what consumers resolve by at-uri. The schemas are what the runtime instantiates against.
Build the lens
The shortest path is to derive it from a schema diff:
schema lens generate old.json new.json --protocol atproto > chain.json
schema is the panproto CLI. chain.json is a protolens chain in
panproto's serialized format. See the
panproto protolens skill for what the chain
looks like and how to inspect it.
Stage it
#![allow(unused)] fn main() { use idiolect_records::{PanprotoLens, AtUri, Datetime}; let chain: serde_json::Value = serde_json::from_slice(&std::fs::read("chain.json")?)?; let lens = PanprotoLens { blob: Some(chain), created_at: Datetime::parse("2026-04-19T00:00:00.000Z").unwrap(), laws_verified: Some(true), object_hash: format!("sha256:{}", sha256_hex(&blob_bytes)), round_trip_class: Some("isomorphism".into()), source_schema: AtUri::parse( "at://did:plc:tutorial.dev/dev.panproto.schema.schema/v1", )?, target_schema: AtUri::parse( "at://did:plc:tutorial.dev/dev.panproto.schema.schema/v2", )?, }; }
Three fields warrant care:
object_hashis a content-addressed identifier for the chain bytes. TheVerifyingResolverwill refuse to hand the lens to the runtime unless the hash matches the canonical bytes.round_trip_classis the optic class panproto's classifier produces (isomorphism,injection,projection,affine,general). Consumers use this to route review.laws_verifiedis a soft assertion that the chain passed panproto's coercion-law and existence checks. A true value here is meaningless without a correspondingdev.idiolect.verificationrecord from a publisher you trust; treat it as a pre-publish smoke signal.
Publish
Construct a SigningPdsWriter from a reqwest PDS client plus a
DPoP prover, wrap it in a RecordPublisher, and call create:
#![allow(unused)] fn main() { use idiolect_lens::{ P256DpopProver, RecordPublisher, ReqwestPdsClient, SigningPdsWriter, }; let client = ReqwestPdsClient::with_service_url(&session.pds_url); let prover = P256DpopProver::from_pkcs8_pem(&pkcs8_pem)?; let writer = SigningPdsWriter::new( client, session.access_jwt.clone(), prover, session.dpop_nonce.clone(), ); let publisher = RecordPublisher::new(writer, session.did.clone()); let resp = publisher.create(&lens).await?; }
pkcs8_pem is converted from the session's
dpop_private_key_jwk via an external JWK-to-PKCS8 helper.
Driving the OAuth dance and persisting the session is the
caller's job; see Configure OAuth sessions. The
PDS rejects the record if the chain blob does not parse, the
schema at-uris do not resolve, or the canonical bytes do not
match the declared object_hash.
Resolve it
The complement of publishing is resolving. Given an at-uri, the
Resolver trait
hands back a PanprotoLens record:
#![allow(unused)] fn main() { use idiolect_lens::{ PdsResolver, ReqwestPdsClient, VerifyingResolver, CachingResolver, Resolver, }; use std::sync::Arc; use std::time::Duration; let client = ReqwestPdsClient::with_service_url("https://bsky.social"); let inner: Arc<dyn Resolver> = Arc::new(PdsResolver::new(client)); let verifying = Arc::new(VerifyingResolver::sha256(inner)); let resolver = CachingResolver::new(verifying, Duration::from_secs(300)); let lens = resolver.resolve(&lens_uri).await?; }
VerifyingResolver re-hashes the bytes the inner resolver returned
and rejects the record on mismatch. CachingResolver keeps the
result in a TTL'd cache so repeated apply_lens calls do not
re-fetch.
Arc<dyn Resolver> is supported (since v0.8.0); the resolver
futures are Send, so handlers in async HTTP frameworks can hold
the resolver behind a trait object and call apply_lens from
inside an #[async_trait] impl.
Make it discoverable
The orchestrator is the catalog the network queries. Once the
firehose indexer ingests your dev.panproto.schema.lens commit,
the orchestrator's lens query will return it. To make consumers
prefer your lens over alternatives:
- Publish a
dev.idiolect.recommendationfrom a community DID endorsing the lens path under stated conditions. - Publish
dev.idiolect.verificationrecords covering the properties consumers care about. - Register the lens in a
dev.idiolect.dialect'spreferredLensesso dialect-aware consumers find it without a separate query.
Migrate records across a revision
A schema you depend on changed. You have records on disk against
the old schema and need them up against the new one. This is
what idiolect-migrate
is for.
The crate is a thin typed façade over panproto-check (for diff
classification) and idiolect-lens (for record translation). It
ships as a library only; there is no idiolect-migrate binary.
The runtime path:
flowchart LR
OLD[record at v1] --> APPLY[apply_lens forward]
APPLY -->|new shape| NEW[record at v2]
APPLY -->|complement| C[complement bytes]
NEW --> EDIT[edit at v2]
EDIT --> PUTBACK[apply_lens_put]
C --> PUTBACK
PUTBACK -->|reconstructed| OLD2[record at v1]
If the lens is an isomorphism the round-trip is byte-equal. If it is a projection, the complement carries the dropped data and the reverse direction reconstructs the original.
Classify the diff
Before generating a lens, classify what changed:
#![allow(unused)] fn main() { use idiolect_migrate::{classify, plan_auto}; use panproto_schema::Schema; let report = classify(&schema_v1, &schema_v2)?; }
classify returns a CompatReport (re-exported from
panproto-check) that distinguishes compatible from breaking
changes. Compatible diffs need no migration: records valid under
v1 remain valid under v2.
Auto-derive the lens
For breaking diffs that are covered by shipped recipes:
#![allow(unused)] fn main() { let plan = plan_auto(&schema_v1, &schema_v2, &hints)?; }
plan_auto returns a MigrationPlan carrying the source and
target schema hashes plus a lens body the caller can publish as
a dev.panproto.schema.lens record.
For breaking diffs that resist automation,
plan_auto returns Err(PlannerError::NotAutoDerivable)
listing the offending changes. The caller writes the lens by
hand.
Migrate one record
#![allow(unused)] fn main() { use idiolect_migrate::migrate_record; let migrated_body = migrate_record( &lens_record, // PanprotoLens record (from a published lens or local plan) &source_record_body, // serde_json::Value &schema_loader, // anything implementing idiolect_lens::SchemaLoader ).await?; }
migrate_record wraps idiolect_lens::apply_lens for the
one-shot case: given a lens, a source record body, and a schema
loader that can resolve both schema hashes, it returns the
migrated target body.
Verify before cutting over
Migration without verification is a guess. Run the round-trip runner against a corpus before treating the migrated tree as authoritative:
#![allow(unused)] fn main() { use idiolect_verify::{RoundtripTestRunner, VerificationRunner, VerificationTarget}; let runner = RoundtripTestRunner::new(/* ... */); let target = VerificationTarget {/* lens, corpus, schema loader, ... */}; let verification = runner.run(&target).await?; }
A Verification { result: Holds, .. } over a representative
corpus is the strongest signal you can get short of formal
proof. The runner returns a Verification record that you can
publish as a dev.idiolect.verification (via
idiolect_lens::RecordPublisher).
Persist the lens record
If the migration is one-shot, the steps above are enough. If you
expect downstream consumers to migrate later, publish the lens
plan's body as a dev.panproto.schema.lens record and link it
from the new schema's preferredLenses list. See
Publish and resolve a lens.
Hand-authored chains
Some migrations are not auto-derivable
(NotAutoDerivable). The release-gate policy in
Lexicon evolution policy
covers that case. The authoring loop is:
- Hand-author the chain in panproto's protolens DSL.
- Run
schema lens inspectto classify it. - Run
schema theory check-coercion-lawsagainst anyCoerceTypestep. - Run the round-trip runner against a corpus snapshot.
- Publish the chain plus a verification record signed by a reviewer.
Each step is mechanical and gated; the policy is what makes migrations reviewable.
Batch migration
The crate ships an idiolect-migrate binary (behind the cli
feature) that walks a directory of JSON records and writes a
migrated directory:
cargo install --path crates/idiolect-migrate --features cli
idiolect-migrate \
--lens at://did:plc:.../dev.panproto.schema.lens/example \
--in ./records-v1/ \
--out ./records-v2/ \
[--pds-url URL]
Records stream one at a time so the working set stays bounded even on multi-million-record corpora. Failed migrations log to stderr and the binary's exit code reflects the worst case (0 if every file succeeded, 1 if any failed).
Configure OAuth sessions
idiolect-oauth
provides the token-store trait and three shipped implementations.
The crate does not implement the OAuth dance itself: that
lives in atrium-oauth-client. The crate also does not ship a
refresh_if_needed helper or a CLI login command; consumers
drive the dance and the refresh decision in their own code,
using OAuthSession's helpers (is_expired, needs_refresh,
refresh_expired) for timing.
When you need it
Anything that publishes records (encounter, recommendation, verification, observation, lens, dialect, vocab, ...) needs an authenticated PDS session. Reading records does not.
Pick a store
| Store | Feature | Use when |
|---|---|---|
InMemoryOAuthTokenStore | (always) | Tests and fixtures. |
FilesystemOAuthTokenStore | store-filesystem | A single operator process running on one host. Sessions live under a directory; one file per DID. |
SqliteOAuthTokenStore | store-sqlite | Multi-process or multi-tenant deployments. Concurrent reads, fsync per write. |
All three implement OAuthTokenStore. Anything that takes
Arc<dyn OAuthTokenStore> accepts any of them.
idiolect-oauth is publish = false; depend via git.
Filesystem store
idiolect-oauth = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["store-filesystem"] }
#![allow(unused)] fn main() { use idiolect_oauth::{FilesystemOAuthTokenStore, OAuthTokenStore}; let store = FilesystemOAuthTokenStore::open("./sessions/")?; // Write a session (returned by the OAuth dance, not by this crate): store.save(&session).await?; // Read it back later: let recovered = store.load(&session.did).await?; }
The directory contains one JSON file per session keyed by DID.
SQLite store
idiolect-oauth = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["store-sqlite"] }
#![allow(unused)] fn main() { use idiolect_oauth::{SqliteOAuthTokenStore, OAuthTokenStore}; let store = SqliteOAuthTokenStore::open("sessions.sqlite").await?; }
Drive the OAuth dance
The dance itself is atrium-oauth-client's job; the crate
returns an authenticated session you store via
OAuthTokenStore::save. The session shape (OAuthSession) is
documented in the crate's source: it carries the DID, PDS URL,
access JWT, refresh JWT, DPoP private key (JWK-serialized),
DPoP nonce, and expiry timestamps as public fields.
For session-staleness decisions, read OAuthSession::is_expired
and OAuthSession::needs_refresh(now, threshold) in your own
refresh path; the crate does not ship a refresh_if_needed
helper, and the application decides how to drive the refresh
endpoint.
DPoP
The session's DPoP keypair is what makes the access token bound.
The signer (the P256DpopProver in
idiolect-lens under
the dpop-p256 feature) consumes the keypair from the session
and signs every PDS write through SigningPdsWriter.
Persisting the DPoP key with the session is the store's job.
Both shipped persistent stores
(FilesystemOAuthTokenStore, SqliteOAuthTokenStore) do; if
you write a custom store, do the same.
idiolect oauth login (transitional)
The idiolect CLI ships an oauth login subcommand that
exchanges a handle + app password for an access JWT via
com.atproto.server.createSession and persists the resulting
session as a JSON file under
$IDIOLECT_SESSION_DIR (default
~/.config/idiolect/sessions/):
idiolect oauth login --handle yourhandle.bsky.social --pds-url https://bsky.social
# password from --app-password or ATPROTO_APP_PASSWORD / ATPROTO_PASSWORD env
idiolect oauth list
idiolect oauth logout --did did:plc:...
This path uses app passwords in legacy Bearer mode.
ATProto is moving off app passwords in favour of OAuth +
DPoP; the next-iteration login UX wraps atrium-oauth's
browser-handoff dance and persists the resulting DPoP-bound
session through the same OAuthTokenStore. The OAuthSession
shape already carries the DPoP private key field; switching
flows is a CLI substitution, not a session-shape change.
refresh_if_needed
idiolect_oauth::refresh_if_needed(&store, &refresher, did)
loads a session, decides whether to refresh based on the
current wall clock plus a 60-second buffer, drives the caller-
supplied Refresher::refresh if so, persists the result, and
returns the live session. Callers who want to drive the
decision themselves read OAuthSession::needs_refresh and
OAuthSession::is_expired directly.
#![allow(unused)] fn main() { use idiolect_oauth::{refresh_if_needed, Refresher, RefreshError, OAuthSession}; struct MyRefresher { /* http client, auth-server URL, ... */ } impl Refresher for MyRefresher { async fn refresh(&self, session: &OAuthSession) -> Result<OAuthSession, RefreshError> { // POST refresh_token to the auth-server's token_endpoint. // Return a fresh OAuthSession with new access_jwt / expires_at. todo!() } } let fresh = refresh_if_needed(&store, &MyRefresher { /* ... */ }, "did:plc:...").await?; }
The trait is narrow on purpose: the refresh HTTP call lives in
whatever OAuth client the application uses (atrium-oauth, a
hand-rolled reqwest call, an in-memory fake for tests).
idiolect-oauth owns the storage and timing decision around it.
Run codegen
Codegen keeps the lexicons (under lexicons/dev/idiolect/) the
single source of truth. Anything that lives downstream of a
lexicon (Rust types, TypeScript validators, family modules,
spec-driven HTTP routes and CLI subcommands) is regenerated, not
hand-edited.
The crate is
idiolect-codegen.
It runs as a workspace binary (cargo run -p idiolect-codegen).
Invocation
cargo run -p idiolect-codegen # write the generated tree
cargo run -p idiolect-codegen -- --check # verify no drift
The default mode regenerates. The --check flag re-derives in
memory and byte-compares against the working tree, exiting
non-zero on any drift. CI runs --check on every PR.
What gets emitted
| Output | Source | Where |
|---|---|---|
| Per-record Rust types | lexicons/dev/idiolect/*.json and the vendored lexicons/dev/panproto/* | crates/idiolect-records/src/generated/ |
idiolect-records family module | the shipped lexicons | crates/idiolect-records/src/generated/family.rs |
| Per-record fixtures (Rust) | lexicons/dev/idiolect/examples/*.json | crates/idiolect-records/src/generated/examples.rs |
| TypeScript validators + types | same lexicons | packages/schema/src/generated/ |
| Orchestrator HTTP routes | orchestrator-spec/queries.json | crates/idiolect-orchestrator/src/generated/ |
| Orchestrator CLI dispatcher | orchestrator-spec/queries.json | crates/idiolect-cli/src/generated.rs |
| Observer method descriptors | observer-spec/methods.json | crates/idiolect-observer/src/generated.rs |
| Verifier kind taxonomy | verify-spec/runners.json | crates/idiolect-verify/src/generated.rs |
The three spec files are single JSON documents (not directories); each declares an array of entries that codegen reads.
The drift gate
--check is the drift gate. CI runs it on every PR. A red gate
means somebody edited a lexicon (or a spec) without rerunning
the default mode, or hand-edited a generated file. Both are
fixable by running cargo run -p idiolect-codegen.
Adding a new lexicon
- Drop a JSON document under
lexicons/dev/idiolect/. - Run
cargo run -p idiolect-codegen. - Optional: add a fixture under
lexicons/dev/idiolect/examples/<name>.jsonso theexamplesmodule emits a typed accessor. - Re-run the workspace tests to confirm the family round-trips.
The codegen rejects malformed lexicons (NSID violations, unresolved references) before emitting, so a broken lexicon errors at this step rather than at compile time.
Adding a new spec entry
The orchestrator query, observer method, and verifier runner specs each carry a different shape. The pattern is the same:
- Add a JSON entry to the spec file.
- Run
cargo run -p idiolect-codegen. - Implement the hand-written half (the panproto-expr predicate
for an orchestrator query, the
ObservationMethodimpl for an observer method, theVerificationRunnerimpl for a verifier runner).
The dispatcher routes to the new entry by its kind; the generated tree handles parsing and shape.
Library API
For consumers outside the workspace, the emitter is callable as a library:
#![allow(unused)] fn main() { use idiolect_codegen::emit::{emit_rust, emit_typescript}; use idiolect_codegen::emit::family::{FamilyConfig, idiolect_family}; }
emit_rust(docs, examples, family) and
emit_typescript(docs, examples, family) take pre-loaded
LexiconDoc and Example slices plus a FamilyConfig, and
return a Vec<EmittedFile>. Loading the lexicons from disk is
the caller's job.
FamilyConfig::new(marker_name, id, nsid_prefix) constructs a
config from any string-like inputs; the shipped default for
dev.idiolect.* is idiolect_family().
The crate is publish = false and not on crates.io; downstream
consumers depend on it via a path or git reference rather than
a registry version.
Author a community vocabulary
Open-enum slugs across the idiolect lexicons resolve through a
dev.idiolect.vocab record. That record carries a typed
multi-relation knowledge graph: nodes (concept, relation, instance,
type, collection) and edges with a relation slug, plus full OWL
Lite property characteristics and SKOS Core annotations.
This guide covers the authoring side: writing the JSON, declaring relation properties, and publishing the record.
Minimum vocabulary
Every vocabulary needs a name, a description, and at least one node:
{
"$type": "dev.idiolect.vocab",
"name": "vote-stances",
"description": "Default deliberation vote stances.",
"world": "open",
"nodes": [
{ "id": "agree", "kind": "concept", "label": "Agree" },
{ "id": "disagree", "kind": "concept", "label": "Disagree" },
{ "id": "pass", "kind": "concept", "label": "Pass" }
],
"edges": [],
"occurredAt": "2026-05-01T00:00:00.000Z"
}
world controls whether unknown values are accepted as
extensions:
open— unknown values are first-class extensions.closed-with-default— unknown values fall back to a designated default.hierarchy-closed— unknown values are rejected.
Per-relation overrides live on the relation node's metadata.
Add typed relations
A relation is itself a node, with its algebraic properties declared as metadata:
{
"id": "polar_opposite_of",
"kind": "relation",
"label": "Polar opposite of",
"metadata": {
"symmetric": true,
"transitive": false,
"reflexive": false,
"irreflexive": true
}
}
The shipped properties cover the OWL Lite set: symmetric,
asymmetric, transitive, reflexive, irreflexive,
functional, inverseFunctional, plus inverseOf and a per-
relation world override. The runtime walks the asserted edges
and validates them against these properties; contradictions
(symmetric+asymmetric, reflexive+irreflexive) produce a
PropertyContradiction violation at publish time.
Add edges
Now express the relations on top of the nodes:
{
"edges": [
{ "source": "agree", "target": "disagree", "relationSlug": "polar_opposite_of" },
{ "source": "disagree", "target": "agree", "relationSlug": "polar_opposite_of" }
]
}
If the relation is symmetric the runtime walks both directions, so you can author either direction (or both, redundantly).
Annotate with SKOS Core
Every concept node accepts SKOS-style annotations:
{
"id": "agree",
"kind": "concept",
"label": "Agree",
"alternateLabels": ["yes", "+1"],
"hiddenLabels": ["agreed", "agrees"],
"scopeNote": "Use when the voter affirms the statement as written.",
"example": "I agree with the proposal as drafted.",
"notation": "1",
"externalIds": [
{ "system": "wikidata", "id": "Q4116214", "match": "exact" }
]
}
The full annotation set is label, alternateLabels,
hiddenLabels, description (definition), scopeNote, example,
historyNote, editorialNote, changeNote, notation, and
externalIds. The match types on externalIds carry SKOS
semantics (exact, close, broader, narrower, related).
A kind: "collection" plus member_of edges expresses a SKOS
Collection.
Validate
schema check vocab.json
The check runs panproto's structural validation plus the OWL Lite consistency walker. Violations are listed with the offending edge or property.
For programmatic validation, construct a Vocab value and call
VocabGraph::from_vocab(&vocab).validate(); violations are
returned as a Vec<VocabViolation> listing each offending edge
or property.
Publish
Same path as any other record. Construct a writer, wrap it in
RecordPublisher, and call create:
#![allow(unused)] fn main() { use idiolect_lens::{ P256DpopProver, RecordPublisher, ReqwestPdsClient, SigningPdsWriter, }; use idiolect_records::Vocab; let vocab: Vocab = serde_json::from_slice(&std::fs::read("vote-stances.json")?)?; let client = ReqwestPdsClient::with_service_url(&session.pds_url); let prover = P256DpopProver::from_pkcs8_pem(&pkcs8_pem)?; let writer = SigningPdsWriter::new( client, session.access_jwt.clone(), prover, session.dpop_nonce.clone(), ); let publisher = RecordPublisher::new(writer, session.did.clone()); let resp = publisher.create(&vocab).await?; }
pkcs8_pem is converted from the session's
dpop_private_key_jwk via an external JWK-to-PKCS8 helper. The
PDS validates the record against the lexicon before commit;
malformed values surface as commit errors. Driving the OAuth
dance and persisting the session is the caller's job; see
Configure OAuth sessions.
Use the published vocab
Once the vocab is on the network, any open-enum field whose
sibling *Vocab points at your at-uri resolves slugs through your
nodes. Consumers reading the record see one of three things:
- A known slug (matches a node id).
- An
Other(String)value (the slug exists in your vocab but the reading consumer is on an older codegen run). - An unknown value (the slug does not appear in your vocab and
worldis permissive enough to accept it).
The is_subsumed_by, satisfies, and translate_to helpers
emitted on every open-enum type use the vocab to answer
slug-relation questions without the consumer manually walking the
edges. See
The vocabulary knowledge graph for
the semantics.
Bundle records into a dialect
A dev.idiolect.dialect record is a community-published bundle
of "these are the schemas, lenses, and conventions we treat as
canonical". Consumers wanting the dialect's view of the world
fetch one record and follow the references.
The shape is documented in the lexicon reference. The fields most consumers care about:
idiolects— schemas the community treats as canonical.preferredLenses— translations the community prefers.deprecations— entries that were once part of the dialect with replacement pointers.versionandpreviousVersion— the dialect's revision chain.
Author the bundle
The shortest path is to construct the typed record directly:
#![allow(unused)] fn main() { use idiolect_records::{Dialect, AtUri, Datetime}; // Plus the inline `defs` types: SchemaRef, LensRef, Deprecation. let dialect = Dialect { owning_community: AtUri::parse( "at://did:plc:tutorial.dev/dev.idiolect.community/canonical", )?, name: "tutorial canonical".into(), description: Some("...".into()), idiolects: vec![/* schemaRef entries */], preferred_lenses: vec![/* lensRef entries */], deprecations: vec![], version: Some("1.0.0".into()), previous_version: None, created_at: Datetime::parse("2026-04-19T00:00:00.000Z").unwrap(), }; }
Construct the record, then publish it via
idiolect_lens::RecordPublisher::create.
What it does for consumers
Consumers reading a dialect get three things:
- A canonical NSID list. A consumer that has resolved a dialect knows which schemas the community treats as canonical and can filter incoming records accordingly.
- A vocabulary registry by reference. Open-enum slugs in records
whose author belongs to the community resolve against the
dialect's listed vocabularies (consumers read the vocabularies
themselves through their
*Vocabsiblings, not through the dialect). - An audit trail of deprecations. A consumer that sees a
Deprecationentry can keep reading the deprecated NSID for some grace period and route toreplacementafterwards.
Use a dialect at runtime
There is no shipped DialectClient helper. Consumers fetch the
dialect record (via idiolect_lens::PdsResolver's
Resolver::resolve or any other resolver) and walk the typed
fields directly. A small wrapper layer is the right shape if a
consumer wants a dialect.preferred_lens_for(nsid) style
accessor; the substrate ships only the record.
Multiple dialects
Two communities can publish disjoint, overlapping, or contradictory dialects. The substrate treats them as opinions; nothing in the protocol prefers one over another. Consumers pick a resolution policy in their own code:
- first-match — pick the first dialect listed in the consumer's config.
- quorum — accept a translation when of trusted dialects endorse the same lens path.
- merge — union the entries; on collision, fall back to a configured tie-breaker.
The substrate does not ship trait or implementation for any of these: dialect resolution is a consumer decision, and the right shape varies by deployment.
Concepts
The Concepts section explains the model the runtime is built on. The chapters are self-contained but assume basic familiarity with ATProto records and panproto's lens vocabulary.
| Chapter | What it explains |
|---|---|
| Idiolect, dialect, language | The frame the project is named after; what each layer is responsible for. |
| The dev.idiolect.* lexicon family | The shipped lexicons, what each one names, and how they compose. |
| Records as content-addressed signed data | Why ATProto's record model is the substrate; what the runtime gets for free. |
| Lens semantics and laws | The get / put / complement model, GetPut, PutGet, optic classification. |
| Open enums and vocabularies | Why every enum field is open; how *Vocab siblings extend slugs. |
| The vocabulary knowledge graph | The typed multi-relation graph, OWL Lite, SKOS Core, registry queries. |
| Deliberation | The deliberation lexicons, how they relate to belief / recommendation. |
| Observer protocol | Why aggregate state lives in records, not in a central endpoint. |
| Lexicon evolution policy | Every lexicon revision ships with a derived, classified, verified, published lens. |
Idiolect, dialect, language
The project name comes from a deliberate analogy with the linguistic terms.
- An idiolect is one party's choice of schemas, lenses, and conventions. It is what a single PDS (or a single application developer) actually publishes.
- A dialect is the bundle of idiolects a community treats as canonical. It carries a list of preferred NSIDs, preferred lenses, endorsed vocabularies, and deprecations.
- A language is the federated substrate over which idiolects and dialects meet. There is no central registry, no single authority, and no global schema; the substrate is ATProto plus the shipped lexicons.
The three layers correspond to three things in the runtime:
| Linguistic | Runtime artifact | Lexicon |
|---|---|---|
| Idiolect | A single party's records on a PDS | (any record kind) |
| Dialect | A bundle published by a community | dev.idiolect.dialect |
| Language | The federated network of all parties | (the whole dev.idiolect.* family) |
Why this frame
A large protocol benefits from a model where parties can disagree gracefully. Two communities can run incompatible schemas and the substrate accommodates them; a third community can publish a lens between the two and the network can route translations. The frame does not promise a single canonical schema; it ships the machinery for reasoning about plural canonicities.
The properties this gets you:
- No global arbiter. There is no place to file a grievance and no place to extract rent. A community can fork a dialect, ship its own, and let consumers pick.
- Cheap experimentation. Adding an idiolect is a record edit. A community can try a new schema, see who adopts, and either roll it into a dialect or abandon it.
- Auditable convergence. When two communities adopt the same lens, the encounter / observation / recommendation records carry enough structure to make the convergence visible without a central monitor.
Failure modes the frame admits
The frame does not promise that the network converges, that disagreements always resolve, or that bad actors cannot publish records. It admits:
- Silent fragmentation. Two communities ship near-identical schemas under different NSIDs. Consumers see two records where there should be one. The signal is the lens-recommendation density between the two; if no community publishes a lens between them, the fragmentation is permanent.
- Adversarial publishing. A bad actor publishes a vocab that shadows a canonical slug with a different meaning. The containment is at the consumer's policy: prefer vocabs from recognised communities, treat unknown vocabs as unknown.
- Dialect drift. A community changes its dialect record without coordinating with downstream consumers. Old records keep validating; new lens choices route differently. The signal is the deprecation list and the lexicon-evolution gate.
The runtime ships primitives for each of these (recommendation, deliberation, lens classification) but no policy. Policy lives in the consumer.
What is not promised
The frame does not promise a unified ontology, a global identifier scheme, or a single authoritative type for any record kind. It does promise that two parties using the same NSID see records under that NSID with the same wire shape, that lenses between schemas obey their stated laws, and that the lexicon itself does not change shape without an auditable migration.
The chapters that follow cover what each of those promises means in practice.
The dev.idiolect.* lexicon family
idiolect ships sixteen record-kind lexicons plus one shared-defs
lexicon (dev.idiolect.defs) under the dev.idiolect.*
namespace. The record kinds are organized by what they describe.
flowchart TB
subgraph publishing["Publishing layer"]
REC[recommendation]
BEL[belief]
DIA[dialect]
BOU[bounty]
end
subgraph events["Events"]
ENC[encounter]
COR[correction]
end
subgraph aggregate["Aggregate"]
OBS[observation]
VER[verification]
RET[retrospection]
end
subgraph deliberation["Deliberation"]
DEL[deliberation]
DST[deliberationStatement]
DVO[deliberationVote]
DOU[deliberationOutcome]
end
subgraph infrastructure["Infrastructure"]
VOC[vocab]
ADA[adapter]
COM[community]
end
ENC -->|folds into| OBS
DVO -->|tallied as| DOU
REC -.points at.-> VER
BOU -.requires.-> VER
BEL -.cites.-> ENC
BEL -.cites.-> OBS
COR -.corrects.-> ENC
DIA -.bundles.-> REC
DIA -.bundles.-> VOC
The arrows are by-reference relationships; the records themselves are independent.
Lexicon-by-lexicon
| Lexicon | What it names |
|---|---|
dev.idiolect.encounter | One invocation of a lens. Carries the lens, the source schema, the action / material / purpose / actor (use), and the outcome. |
dev.idiolect.observation | Aggregate over encounters folded by an observer. Per-outcome counts plus optional weighted aggregates over a window. |
dev.idiolect.correction | A claim that a specific encounter's outcome was wrong, plus the corrected output. |
dev.idiolect.belief | A community's standing claim about a lens or schema, citing encounters / observations as evidence. |
dev.idiolect.recommendation | A community-published opinionated path: lens chain plus structured applicability conditions, preconditions, caveats, and required verifications. |
dev.idiolect.bounty | A request for someone to do verification work, with structured wantVerification and constraintConformance fields. |
dev.idiolect.verification | The outcome of a verification runner: which lens, which kind, pass/fail, structured report. |
dev.idiolect.retrospection | A post-hoc review of a sequence of encounters / observations, with a structured finding. |
dev.idiolect.dialect | A community-curated bundle: NSIDs, preferred lenses, endorsed vocabularies, deprecations. |
dev.idiolect.community | The community itself: members (with optional roles), record-hosting policy, optional AppView endpoint. |
dev.idiolect.vocab | A typed multi-relation knowledge graph used to resolve open-enum slugs. |
dev.idiolect.adapter | A description of an external surface (subprocess, http, wasm, ...) that consumes idiolect records, with isolation policy. |
dev.idiolect.deliberation | A community-scoped deliberation: topic, classification, status, optional outcome pointer. |
dev.idiolect.deliberationStatement | One statement made inside a deliberation. |
dev.idiolect.deliberationVote | One vote on a statement, with an open-enum stance plus optional weight + rationale. |
dev.idiolect.deliberationOutcome | An observer-published tally: per-statement per-stance counts plus optional adopted-statements. |
The full per-lexicon reference is under Lexicons.
Why this set
The family was assembled to cover four concerns:
- What happened? The encounter / correction / observation triple. One record per invocation, with corrections and folds on top.
- What should happen? The recommendation / belief / dialect / verification quad. Communities express opinions; opinions cite evidence; consumers route translations through them.
- What does this mean? The vocab + open-enum convention. Slugs are open-enum strings resolved through community-published knowledge graphs.
- What did we decide? The deliberation quad (deliberation, statement, vote, outcome). A process-shaped counterpart to the settled-belief shape.
A new record kind that fits into one of those four columns is a candidate for the family. A record kind that does not fit is likely a downstream extension; the Bundle records into a dialect guide covers how to ship one.
Composition with downstream lexicons
idiolect's shape is meant to be the substrate, not the ceiling. A
downstream community publishes its own NSID family and uses
OrFamily<IdiolectFamily, MyFamily> at the indexer boundary so its
records flow alongside idiolect's. Lenses bridge the two. A
dialect record from the downstream community lists both
families' canonical NSIDs.
The shipped example of this pattern is the planned idiolect-acorn
bridge; the design is in notes/.
Records as content-addressed signed data
Every artifact in idiolect is an ATProto record. A record is:
- a JSON object,
- with a
$typefield naming a lexicon, - stored at a
(did, collection, rkey)triple, - content-addressed by its CID,
- signed by the publishing repo's signing key.
The substrate is documented in the ATProto spec. This chapter covers the properties idiolect relies on.
Properties idiolect relies on
1. Records are signed
Every commit is signed by the repo's signing key. A consumer fetching a record can verify the signature against the repo head and the head against the canonical PLC directory entry. idiolect's trust model is rooted in those signatures: a verification record is only as trustworthy as its signer.
The runtime does not re-validate signatures on every read. The shipped path is:
sequenceDiagram
Consumer->>PDS: getRecord(uri)
PDS-->>Consumer: { value, cid, signed_commit }
Consumer->>PLC: resolve did
PLC-->>Consumer: signing_key
Consumer->>Consumer: verify cid against signed_commit
Consumer->>Consumer: verify signed_commit against signing_key
The verification step is what idiolect-identity plus the
VerifyingResolver in idiolect-lens give you. Cache the
resolved signing key, re-fetch only on cache miss, and treat a
mismatch as a hard error.
2. Records are content-addressed
A record's CID is derived from its canonical bytes. Two records
with the same content have the same CID. The CID is what the lens
record's object_hash field carries, and what the
VerifyingResolver checks before instantiating a lens. A
malicious upstream cannot serve a different lens under the same
at-uri without changing the CID, and the CID change is observable.
3. Records compose by reference
A record can reference another record by at-uri or by strongRef
(at-uri + CID). A strongRef is content-addressed, so the
reference points at exactly one byte sequence. An at-uri is a
mutable pointer.
idiolect uses strongRef for evidence (belief.evidence,
correction.encounter, verification.lens) and at-uri for
queries that should follow updates (recommendation.lensPath,
dialect.entries[].vocab). The choice in each lexicon is
deliberate; see the per-lexicon reference for the rationale.
4. Records survive PDS migration
ATProto's identity layer (PLC plus did:web) lets a repo move
between PDSes without changing its did. A record fetched by
at-uri after a PDS migration goes through one extra DID-resolve
hop and arrives at the new PDS. idiolect's runtime path goes
through idiolect-identity for every fetch; PDS migration is
transparent.
Properties idiolect adds on top
5. Lexicon validation at the boundary
Every shipped record kind validates against its lexicon at parse
time. A field that violates a format, maxLength, or
required constraint fails to deserialize before any business
logic runs. The boundary is exactly where you want it.
6. Family-typed dispatch
The codegen-emitted family modules (idiolect_records::IdiolectFamily)
let consumers be generic over the family. A firehose handler that
takes a RecordHandler<F: RecordFamily> filters out-of-family
commits before decode. The crate-level reference covers
OrFamily<F1, F2> for composing families.
7. Open enums
Every enum-shaped field is an open enum: known values are typed
constants, unknown values fall through to Other(String), and the
sibling *Vocab field points at a dev.idiolect.vocab record
where unknown values resolve. This is what lets two communities
extend the same field without a centralized governance step. See
Open enums and vocabularies.
8. Internal records do not federate
Runtime state that should not federate (firehose cursors, OAuth
tokens) uses the same panproto schema apparatus, but under a
sibling dev.idiolect.internal.* namespace. Conformant firehose
consumers skip the prefix; the data still travels through the
same runtime as a public record, just out of band.
What ATProto does not give you
- A schema language. ATProto Lexicon is a constrained type language; it does not cover lens algebra, schema diffs, or optic classification. Those live in panproto, which idiolect embeds.
- A migration story. Lexicon revision is wire-compatible by policy, not by tooling. The lexicon-evolution policy fills the gap; see Lexicon evolution policy.
- A vocabulary registry. Open-enum slugs need a published
knowledge graph to resolve; ATProto does not ship one. The
dev.idiolect.vocabrecord is where the resolution lives.
Lens semantics and laws
A lens is a structured pair of functions between two schemas:
get translates a source value A into a target view B plus a
complement (the data the projection discarded).
put takes a (possibly modified) B and the complement, and
reconstructs an A.
This shape is panproto's state-based asymmetric lens and is
what idiolect-lens runs on the wire.
The laws
A well-formed lens obeys two laws.
GetPut
Restoring the complement recovers the original.
In runtime form: if you apply_lens a record forward and then
apply_lens_put it back, you get the source bytes you started
with.
PutGet
Lifting then projecting gives back the modified view.
In runtime form: if you write a target view through put and
then read it back through get, the view and complement match
what you wrote.
Iso laws
When the lens is an isomorphism (no information dropped), the complement is empty and the laws collapse:
The two directions are total inverses.
Optic classification
panproto's classifier assigns each lens chain one of five classes, based on what the chain promises:
| Class | What it promises | What it allows |
|---|---|---|
| Iso | Bijective; both directions are total inverses. | Auto-merge under the lexicon-evolution policy. |
| Injection | Source embeds in target without loss. Forward is total; backward needs no complement. | Auto-merge as forward-only. |
| Projection | Target is a quotient of source; forward drops information. Backward needs the complement. | PR review under the policy. |
| Affine | Partial. The forward direction may fail on some inputs. | PR review plus a community recommendation. |
| General | None of the above. | Manual lens authoring, full coercion-law check, plus verification. |
The class is not a quality judgment; it is a routing decision. Some legitimate migrations are projections (a field genuinely went away). The policy makes the consequences visible.
Composition
Chain composition is associative:
Identity is the no-op lens; it is a left and right identity for
composition. panproto's protolens runtime auto-simplifies adjacent
steps where it can (RenameVertex(a,b) ; RenameVertex(b,c) → RenameVertex(a,c)).
Symmetric lenses
A symmetric lens pairs two state-based lenses that share a middle schema:
Sync from to goes , threading the
complement through both halves. The dual sync uses the
same machinery in reverse. apply_lens_symmetric runs either
direction.
This is the right shape for bridging two communities' lexicons: each community owns its own lens to a shared middle schema, and the bridge stays consistent up to complement.
Coercion honesty
Lens chains that cross primitive kinds (Int↔Str, Float↔Int, ...)
declare a CoercionClass per kind crossing:
CoercionClass | When |
|---|---|
Iso | Forward and inverse are total inverses (e.g. Int to its decimal string and back). |
Retraction | Forward is total; inverse recovers the forward image only. |
Projection | Forward drops information (e.g. Float to Int by truncation). |
Opaque | Documentation pair; no round-trip promise. |
A dishonest Iso declaration silently corrupts the GetPut law.
panproto ships a sample-based law checker
(schema theory check-coercion-laws) that catches violations.
The checker is wired into the lexicon-evolution gate.
What you have to verify
Stating the laws is cheap. Verifying them on a corpus is the work. The shipped runner kinds:
roundtrip-testruns GetPut on a corpus.property-testruns an arbitrary boolean predicate.static-checkruns the panproto-level coercion-law and existence checks against the chain itself.
A lens with no published verifications is a claim; a lens with multiple published verifications from trusted signers, run on recent corpora, is closer to an asserted fact. See Author a verification runner for the authoring path.
Open enums and vocabularies
Closed enums are the wrong default for a federated lexicon. Adding a value should not require coordinating with every consumer; refusing to accept a new value should not be the default.
idiolect's policy is: every enum-shaped field is open, and the extension story is mechanical.
The wire shape
An open-enum field carries knownValues and a sibling *Vocab
reference:
"kind": {
"type": "string",
"knownValues": ["subprocess", "http", "wasm"],
"description": "Slug; resolves as a node in the vocab referenced by `kindVocab`."
},
"kindVocab": {
"type": "ref",
"ref": "dev.idiolect.defs#vocabRef",
"description": "Vocabulary record whose nodes constitute the open extension."
}
kindVocab is optional. When omitted, the canonical
idiolect-published vocab for that field is the implicit default.
A community-published vocab listed here extends the slugs the
field accepts.
The codegen shape
idiolect-codegen reads knownValues and emits:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Eq, PartialEq)] pub enum Kind { Subprocess, Http, Wasm, Other(String), } }
Plus hand-written Serialize / Deserialize impls that round-trip
unknown slugs through Other(String). The TypeScript half emits
'a' | 'b' | (string & {}) for the same purpose.
Three helper methods sit on every emitted open-enum type:
#![allow(unused)] fn main() { impl Kind { pub fn is_subsumed_by( &self, graph: &VocabGraph, ancestor: &str, ) -> bool { /* ... */ } pub fn satisfies( &self, graph: &VocabGraph, relation: &str, target: &str, ) -> bool { /* ... */ } pub fn translate_to<T: From<String>>( &self, src_vocab_uri: &str, tgt_vocab_uri: &str, registry: &VocabRegistry, ) -> Option<T> { /* ... */ } } }
These are what consumers call instead of comparing strings. A
consumer asking "is this kind a subprocess?" calls
k.is_subsumed_by(&vocab, "subprocess") and gets true for any
slug the vocab declares as subsumed_by subprocess (docker-run,
fly-machines-launch, etc.) without changing the consumer's code.
Why this shape
Closed enums force a coordination problem: adding a value requires
every consumer to upgrade their code before any producer publishes
the new value. Open enums turn it into a vocabulary problem: the
producer publishes the vocab, the consumer queries the vocab at
runtime, and unknown slugs degrade gracefully to Other(String)
when the consumer has not loaded the vocab.
The cost is one extra indirection per slug interpretation. The
shipped VocabRegistry caches vocabs by at-uri, so the cost is
amortized across the process lifetime.
What stays closed
A few fields are intentionally closed. They are meta-policy fields where extending the value space would change the runtime's contract, not the data:
vocab.world(open/closed-with-default/hierarchy-closed) controls the runtime's open-enum policy itself.lensClass(isomorphism/injection/projection/affine/general) is a panproto contract; extending it changes what the runtime promises.recordHosting(member-hosted/community-hosted/hybrid) controls a federation policy.
A new value here is a runtime change, not a record change.
Migration
Converting a closed enum to an open enum is wire-compatible:
existing records continue to validate, and the codegen-emitted
helpers degrade to "if Other, ignore" in consumers that have
not regenerated. Going the other way is breaking; the shipped
lexicons do not do that.
Codegen identifier collisions
When two distinct slugs would pascal-case to the same Rust
identifier (foo-bar and foo_bar), the second occurrence gets
a numeric suffix (FooBar2). The collision is resolved
deterministically per lexicon, so two regenerations of the same
lexicon produce the same identifier names. The collision report
is printed at codegen time so authors can rename a slug when the
generated name is awkward.
The vocabulary knowledge graph
dev.idiolect.vocab is a typed multi-relation knowledge graph.
A vocabulary record carries:
- Nodes with a
kinddiscriminator (concept,relation,instance,type,collection). - Edges where and are node ids and is a relation slug.
- Relation metadata declaring algebraic properties of .
- SKOS Core annotations on every node.
- External-id mappings for cross-system grounding.
The shape is modelled on pub.chive.graph.{node,edge}.
Why a graph, not a tree
Earlier idiolect releases shipped a single-relation tree (the
subsumed_by parent pointer). That covers the canonical
hierarchical case (a docker-run is a subprocess) but not:
- Multiple relations on the same nodes (
equivalent_to,polar_opposite_of,instance_of,member_of, ...). - Cross-vocabulary translation. A node in one vocab and a node in
another can be linked by
equivalent_toso consumers can follow the bridge. - Algebraic properties beyond reflexivity (
symmetric,transitive,inverse_of, ...). These are the OWL Lite predicates.
The graph form generalises the tree: subsumed_by is just one
relation among many.
Node kinds
| Kind | What it represents |
|---|---|
concept | A SKOS concept; the typical case. |
relation | A relation type. The node carries the relation's metadata; edges of that kind reference this node by slug. |
instance | A specific entity (a did, an at-uri, a real-world identifier). |
type | A type-of-types; rare but useful for meta-vocabularies. |
collection | A SKOS Collection. Members are linked by member_of edges. |
OWL-Lite-style property characteristics
A relation-kind node carries metadata declaring the relation's
algebraic properties. The shipped set follows the OWL Lite
terminology adopted by the project; strictly, it spans both
OWL Lite (symmetric, transitive, functional,
inverseFunctional, inverseOf) and OWL 2 additions
(asymmetric, reflexive, irreflexive).
| Property | Meaning |
|---|---|
symmetric | |
asymmetric | |
transitive | |
reflexive | for all in scope |
irreflexive | for all in scope |
functional | |
inverseFunctional | |
inverseOf | A pointer to the inverse relation node. |
world (per-relation override) | Open-enum policy for this relation only. |
Contradictions (symmetric+asymmetric, reflexive+irreflexive)
are caught at validation time; functional / inverse-functional
violations are caught at edge-walk time. The VocabGraph::validate
walker emits one
VocabViolation per
inconsistency.
SKOS Core annotations
Every concept-kind node accepts the SKOS Core annotation set:
| Field | SKOS counterpart |
|---|---|
label | prefLabel |
alternateLabels | altLabel |
hiddenLabels | hiddenLabel |
description | definition |
scopeNote | scopeNote |
example | example |
historyNote | historyNote |
editorialNote | editorialNote |
changeNote | changeNote |
notation | notation |
externalIds[] | exactMatch / closeMatch / broadMatch / narrowMatch / relatedMatch |
The annotations do not affect runtime resolution (the slug is the key); they are surface-area for human authors and downstream display tooling.
Runtime queries
VocabGraph is a normalised read-only view over a single vocab.
The shipped methods on it:
walk_relation(source, relation, reflexive) -> Vec<String>— the canonical traversal primitive.subsumed_by(source) -> Vec<String>— the transitive + reflexivesubsumed_bywalk fromsource.is_subsumed_by(specific, general) -> bool— the legacy hierarchical query.direct_targets(source, relation) -> &[String]anddirect_sources(target, relation) -> &[String]— one-hop neighbours.equivalent_in(source, other) -> Option<String>— find the equivalent slug in another graph viaequivalent_toedges.top()/top_with(explicit)— the vocab's top node undersubsumed_by.relation_properties(relation) -> RelationProperties— the OWL Lite metadata for the named relation.validate() -> Vec<VocabViolation>— the OWL Lite consistency walker.
VocabRegistry caches multiple graphs by AT-URI for
cross-vocabulary work. It exposes:
insert(uri, &vocab)/get(uri)/len()/is_empty()— registry plumbing.is_subsumed_by(uri, specific, general) -> Option<bool>andsatisfies(uri, x, relation, y) -> Option<bool>— query a named vocab.Nonemeans the registry has no entry for that URI.translate(from_uri, to_uri, slug) -> Option<String>— walkequivalent_toedges to translate a slug across vocabs.validate() -> BTreeMap<String, Vec<VocabViolation>>— batch validate every registered vocab.
The exact signatures and return types are on docs.rs at
idiolect_records::VocabGraph.
Cross-vocab translation
Two vocabs that publish equivalent_to edges between their nodes
form an undirected bridge. A consumer holding both records and a
VocabRegistry can ask:
#![allow(unused)] fn main() { let translated: Option<String> = registry.translate( &from_uri, &to_uri, "agree", // a slug in the from-vocab ); }
If the bridge exists, the consumer gets the equivalent slug in
the target vocab. If it does not, None. This is the engine for
Open enums' translate_to helper.
Versioning
A vocab edit is a record edit (a new dev.idiolect.vocab record)
under a new rkey, with the old vocab listed as supersedes. The
new record is a separate at-uri; consumers depending on the old
slug set continue to resolve through the old vocab until they
update their references.
The lexicon-evolution policy classifies vocab edits the same way it classifies schema edits:
- Adding a node or an edge — Iso (open enums tolerate).
- Renaming a node — Iso via
RenameEdgeName. - Removing a node — Projection over consumers using that node.
- Adding
equivalent_tobetween two vocabs — derives a free lens available through the orchestrator'smapEnumcache.
The full classification is in Lexicon evolution policy.
Deliberation
The deliberation lexicons (deliberation,
deliberationStatement, deliberationVote,
deliberationOutcome) name a process. The belief /
recommendation lexicons name results. They are deliberately
distinct.
What each lexicon is for
flowchart LR
DEL[deliberation] -->|topic, status| DST[deliberationStatement]
DST -->|subject of| DVO[deliberationVote]
DEL -->|tallied as| DOU[deliberationOutcome]
DOU -->|adopts| DST
dev.idiolect.deliberationdeclares a community-scoped topic. Carries the owning community, an open-enumclassification(question / proposal / grievance / ...), an open-enumstatus(open / closed / tabled / adopted / ...), and an optional pointer to the resulting outcome.dev.idiolect.deliberationStatementis one statement made inside a deliberation. Carries the deliberation it belongs to, the text, an open-enumclassification(claim / proposal / dissent / clarification / ...), and ananonymousflag with an optionalauthoredOnservice-DID surrogate.dev.idiolect.deliberationVoteis a vote on a statement. Carries the statement (as astrongRef), an open-enumstance(defaults toagree/pass/disagree), an optionalweightinteger, and an optionalrationale.dev.idiolect.deliberationOutcomeis an observer-published tally. Carries the deliberation, per-stance counts per statement, thecomputedAttimestamp, and an optional list of adopted statements.
Why this is separate from belief and recommendation
A dev.idiolect.belief is a community's standing claim about a
lens or schema. A dev.idiolect.recommendation is an opinionated
path with conditions. Both are settled artifacts: they record what
a community thinks, not how a community arrived there.
Deliberation is the process. The four lexicons together let consumers see what the community considered, who voted, what the tally was, and which statements were adopted, before reading the resulting belief or recommendation. A consumer that only ever sees the belief is in the same position as a consumer of any asserted truth; a consumer that wants context can follow the deliberation.
Maps to Acorn's assembly records
Bluesky's Acorn project publishes the same shape under
community.blacksky.assembly.{conversation, statement, vote}.
The four idiolect lexicons are shaped so a future bridge to
Acorn's records can be lossless. Stance, classification, and
status are open-enum slugs resolved through community-published
vocabularies; Acorn's -1 | 0 | 1 stances become vocab nodes.
The bridge crate is downstream work and is not shipped. The lexicons are; they were designed against the assembly records as a forcing function.
How the tally is produced
A deliberationOutcome is observer-published, not voter-published.
The fold (in idiolect-observer) walks every deliberationVote
that points at a statement in the deliberation, tallies stances
per statement, and emits one outcome record per (deliberation,
window) tuple.
Multiple observers can publish concurrent outcomes for the same
deliberation; consumers can require quorum across observers
before adopting an outcome. This is the same shape as the
observation fold over encounters.
Open-enum extension
The shipped vocabularies seed canonical defaults:
deliberation-classifications(question, proposal, grievance, process, position, ...).statement-classifications(claim, proposal, dissent, clarification, question, ...).deliberation-statuses(open, closed, tabled, adopted, rejected, ...).vote-stances(agree, pass, disagree, withpolar_opposite_ofedges).
A community publishing its own vocab over any of these slugs can
extend the value set without modifying the lexicons. Records
referencing the community's vocab through the corresponding
*Vocab field resolve through the extended slug set.
What this is not
The deliberation lexicons do not implement Polis-style clustering, quadratic voting, or any specific decision procedure. They name the artifacts of a deliberation; the procedure that produces the artifacts (and the procedure that adopts an outcome) is the community's choice.
A community that wants quadratic voting publishes a
vote-weights vocabulary and uses the optional weight field on
deliberationVote to carry its scheme. A community that wants
delegation publishes a delegations mapping (out-of-band or in
its own lexicon) and lets observers fold votes along the
delegation chain. The substrate carries the votes; the procedure
carries the meaning.
Observer protocol
Aggregate state in idiolect is records, not endpoints. An observer is a process that reads encounter-family records from the firehose, folds them along a key, and publishes the fold as a record.
What an observer is
flowchart LR
PDS[(PDS firehose)] -->|encounters| OBS[Observer process]
OBS -->|fold by lens, window, kind| OBSREC[observation records]
OBSREC --> PDS2[(observer's PDS)]
PDS2 --> CONSUMER[Consumer]
CONSUMER -->|verifies signature| OBS
- The observer subscribes to the firehose.
- It accumulates encounters into a windowed bucket keyed by
(lens, kind, window). - At window close, it computes the fold (per-outcome counts plus
optional weighted aggregates) and publishes one
dev.idiolect.observationrecord. - The record is signed by the observer's DID.
The shape generalises beyond the observation lexicon. The same
fold path runs over deliberationVote records to produce
deliberationOutcome records (per-statement per-stance tallies
plus optional adopted-statements).
Why records, not metrics
A central metrics endpoint cannot:
- Be verified after the fact. Once the endpoint serves a counter, the counter's history is whatever the endpoint says it is.
- Be re-folded by an independent party. A consumer that distrusts the operator cannot re-derive the count from the underlying data.
- Disagree with itself across observers. There is one operator, one number.
A signed record can be:
- Verified against the signer's key. The same trust model as any ATProto record.
- Re-folded from the underlying encounter records. A consumer reading an observation can ask the indexer for the encounters in scope and recompute the fold independently.
- Compared across observers. Two observers running the same fold on overlapping data will produce records with comparable counts; consumers can require quorum before treating an observation as authoritative.
The cost is an extra serialization per fold and an extra commit per window. The shipped daemons amortize this over the window duration.
Fold shapes
A fold is a deterministic function:
over the encounters visible in the window, producing one record . Determinism matters because the consumer recomputing the fold should get the same result the observer published. The constraints:
- The fold reads a stable view of the encounters in scope. The observer commits the cursor only after the fold is published, so a restart re-folds the window.
- The fold does not branch on local time. The window timestamp is
derived from the encounters'
occurredAt. - The fold does not branch on local randomness. Anything derived from random sampling has to commit the seed in the record.
The shipped methods (declared in observer-spec/methods.json):
| Method | Folds |
|---|---|
correction-rate | Per-lens correction counts grouped by reason. |
encounter-throughput | Encounter traffic by kind and downstream result. |
verification-coverage | Per-lens verification counts by kind, result, and distinct verifiers. |
lens-adoption | Per-lens encounter count and distinct invokers. |
action-distribution | Encounter counts grouped by use.action. |
purpose-distribution | Encounter counts grouped by use.purpose. |
basis-distribution | Record counts grouped by basis variant. |
attribution-chains | dev.idiolect.belief counts by holder and subject. |
All eight produce dev.idiolect.observation records. A
deliberation-tally method that produces
dev.idiolect.deliberationOutcome records is a plausible
addition but is not in the shipped set at v0.8.0.
The spec is a single JSON file (observer-spec/methods.json),
not a directory of files; codegen emits the descriptor table.
See Run the observer daemon for the
operator-facing path.
Coordination among observers
Two observers running the same fold will produce records that agree up to:
- The window boundary they chose. Observers should align on a shared cadence (e.g. UTC-aligned 1-hour windows).
- Late-arriving encounters. Encounters posted after the window closes will be folded into the next window.
- The encounter scope the observer indexed. An observer that missed a firehose segment will have a different count than one that did not.
Consumers that want consensus require of trusted observers to publish records that agree within a tolerance. This is a consumer policy; the substrate ships the records.
Why folds are the right primitive
A simple primitive — the observer publishes an aggregate signed by its DID — supports a lot of structure:
- Quorum (a consumer requires multiple observers to agree).
- Reputation (a consumer prefers observers with a track record).
- Delegation (a consumer treats one observer's records as authoritative when it does not want to fold itself).
- Fork detection (two observers' aggregates over the same window diverging is a signal that one of them is missing data).
None of those need protocol changes. They are policies on top of records.
Lexicon evolution policy
Every lexicon revision in idiolect ships with an auto-derived, classified, verified, published lens. Hand-authored lenses are an escape hatch that requires governance sign-off. The same policy applies to vendored externals (Blacksky, layers-pub, ...).
The policy is the lexicon-level half of the project's stability
story; the schema-level half is the
stability and versioning note. The
policy below is enforced by scripts/lexicon-evolve.sh and the
release CI workflow.
The six stages
flowchart LR
DIFF[0. Diff] --> DERIVE[1. Auto-derivation]
DERIVE --> CLASSIFY[2. Classify]
CLASSIFY --> COERCE[3. Coercion-law check]
COERCE --> ROUNDTRIP[4. Roundtrip verify]
ROUNDTRIP --> PUBLISH[5. Publish lens]
Each stage maps onto a panproto primitive; nothing is bespoke.
Stage 0 — Diff
schema diff --src lexicons/<nsid>.<old>.json --tgt lexicons/<nsid>.<new>.json
Produces a structured change graph: vertex / edge additions,
removals, renames, kind coercions, constraint tightenings. Cached
under migrations/<nsid>/<old>-<new>/diff.json.
Stage 1 — Auto-derivation
schema lens generate <old>.json <new>.json --hints <hints>.json
Produces a protolens chain: a sequence of dependent optics, each parameterized by a precondition over schemas. The elementary constructors are listed in Lens semantics. Hints declare anchors for ambiguous renames; forward-chaining propagates declared anchors into derived ones before the CSP solver runs.
Stage 2 — Classify
schema lens inspect chain.json --protocol atproto
Each chain receives one of five optic classes. The class drives the gate:
| Class | Gate behavior |
|---|---|
| Iso | Auto-merge. No governance review. |
| Injection | Auto-merge as forward-only. |
| Projection | PR review required. Complement persistence required. |
| Affine | PR review plus a dev.idiolect.recommendation from a recognised reviewer. |
| General | Manual lens authoring. Coercion-law check, verification gate, and recommendation all required. |
The class also feeds dev.idiolect.dialect#deprecations: any
non-Iso lens revision implicitly deprecates the previous schema
and must populate deprecations with the lens at-uri as
replacement.
Stage 3 — Coercion-law check
For any CoerceType step crossing primitive kinds:
schema theory check-coercion-laws theory.ncl --json
Sample-based; exit code is non-zero on any falsifying sample. The
chain declares each CoerceType with an honest CoercionClass.
Dishonest declarations corrupt the asymmetric-lens put law
silently; this gate catches them.
Stage 4 — Roundtrip verification
schema lens verify <corpus>/ --protocol atproto --schema <new>.json --chain chain.json
Checks GetPut and PutGet over the corpus. The corpus is the live indexer's catalog snapshot at the time of revision: actual records published across the network for that NSID. CI fails on any record that violates either law. Verification is grounded in real data, not synthetic test cases.
Stage 5 — Publish
schema lens inspect chain.json --json | idiolect-cli publish-lens \
--collection dev.panproto.schema.lens
The verified chain serializes to Nickel via panproto-lens-dsl
and ships as a dev.panproto.schema.lens record from idiolect's
DID. The lens at-uri is added to:
- The new lexicon revision's
dev.idiolect.dialect#preferredLenses. - The previous lexicon's
deprecationsblock asreplacement.
Codegen re-runs on revision bump; downstream consumers pulling the dialect record see the lens automatically.
Vocab edits go through the same pipeline
Vocab edits are record edits, not schema edits, but the same six stages apply via dependent optics:
| Edit | Class | Action |
|---|---|---|
| Add node, add edge | (no migration needed) | Open enums tolerate. Stage 4 corpus regression only. |
| Remove node | Projection | Full pipeline. |
| Rename node | Iso | RenameEdgeName over consumers. Auto-merge. |
Add equivalent_to between vocabs | (free) | Triggers a derived lens automatically lifted into the orchestrator's mapEnum cache. |
Why this is modular
- Modular. Each elementary protolens is a stand-alone, well-typed combinator. The pipeline composes them; no bespoke migration code per revision.
- Abstract. Protolenses are quantified over schemas, not specific to a revision pair.
RenameField("oldName", "newName")is a schema-parametric morphism; it applies to the lexicon and to every record across the network without per-record code. - Composable. Chain auto-simplification, ScopedTransform sub-chains, Nickel record merge for fragments, symmetric lenses for forward / backward pairing, lift across protocols via theory morphisms.
- Verifiable. Optic classification is mechanical. Coercion-law checks are sample-based. Corpus regression uses real records. Trust comes from the substrate, not from review prose.
- Decentralized. Communities author their own protolens chains for their own lexicons. The policy applies to anyone adopting idiolect's framework.
Vendored externals
Vendored lexicons (Blacksky, layers-pub, ...) are consumed as
schemas idiolect does not own. When they revise, the same six
stages run against their old / new pair. The output is a
symmetric lens (per panproto's symmetricLens): syncing A → B
and B → A keeps both sides consistent up to complement. This is
the right shape for a bridge crate (e.g. the planned
idiolect-acorn): when the upstream changes, the bridge auto-
updates and downstream idiolect records remain syncable.
Tooling
scripts/lexicon-evolve.sh <nsid> <old> <new>runs stages 0–5 in sequence..github/workflows/lexicon-evolution.ymlruns stages 3–4 on PR; stage 5 on tagged release.- A pre-commit hook runs stages 0–2 locally on every lexicon edit.
migrations/<nsid>/<old>-<new>/{diff.json, chain.ncl, hints.json, classification.json, verification.json}is the per-revision audit trail.
The policy is what makes lexicon evolution reviewable. The tooling is what makes the policy cheap to follow.
Reference
Per-symbol detail. Use the navigation to jump to a specific crate, lexicon, CLI subcommand, or HTTP endpoint.
| Section | Contents |
|---|---|
| Crates | One page per workspace crate, with public types, traits, error variants, and feature flags. |
| Lexicons | One page per dev.idiolect.* lexicon, with field-by-field shape. |
| CLI | Every shipped idiolect subcommand, its flags, and its output. |
| HTTP query API | Every endpoint exposed by the orchestrator, request and response shape. |
| Stability and versioning | The pre-1.0 stability policy. |
The reference covers the 0.8.0 release. For older releases, see
the
release archive.
Authority policy
This section is editorial. For Rust crates, the authoritative
per-symbol reference is the rendered rustdoc on docs.rs (linked at
the top of every crate page). For lexicons, the authoritative
shape is the JSON document under lexicons/dev/idiolect/. When
this book and either source disagree, the source wins; please
file an issue.
Crates
The workspace ships eleven crates. Each is independently versioned but bumped together at every release.
| Crate | Purpose |
|---|---|
| idiolect-records | Generated record types for the dev.idiolect.* lexicons; Record trait; family modules. |
| idiolect-codegen | Lexicon-driven Rust + TypeScript emitter; drift gate; breaking-change classifier. |
| idiolect-lens | Resolve PanprotoLens records; run apply_lens. |
| idiolect-identity | DID resolution (did:plc, did:web). |
| idiolect-indexer | Firehose consumer with pluggable stream / handler / cursor store. |
| idiolect-oauth | OAuthTokenStore trait and shipped impls. |
| idiolect-observer | Fold encounter-family records into observation records. |
| idiolect-orchestrator | Read-only HTTP query API over a record catalog. |
| idiolect-verify | Verification runners with declarative dispatch. |
| idiolect-migrate | Schema diff plus lens-based record migration. |
| idiolect-cli | Command-line tool wrapping the library crates. |
Cargo manifests live under crates/<name>/Cargo.toml. Every
shipped crate is published to crates.io under the same name and
to docs.rs at https://docs.rs/<name>/latest/<name_underscored>/.
Policy
The pages in this section are editorial overviews: an opinionated summary of what each crate is for, the public types you reach for first, and the feature flags. They are not the authoritative per-symbol reference. The authoritative reference is the rendered rustdoc on docs.rs, linked at the top of every crate page. When this book and docs.rs disagree, docs.rs is right.
idiolect-records
API reference: docs.rs/idiolect-records · Source:
crates/idiolect-records/· Crate: crates.io/idiolect-recordsThis page is an editorial overview. The per-symbol surface (every public type, trait, function, and feature flag) is the docs.rs link above; that is the authoritative reference.
Serde record types mirroring the dev.idiolect.* lexicons. The
contents of crates/idiolect-records/src/generated/ are written
by idiolect-codegen; do not edit by
hand.
[dependencies]
idiolect-records = "0.8"
No transport dependencies. Pure data.
Public types
Record trait
Every generated record type implements Record, with associated
constants and methods that let consumers be generic over the
family.
AnyRecord enum
The dispatch primitive returned by
decode_record(&nsid, value). One variant per shipped
dev.idiolect.* record kind (Adapter, Belief, Bounty,
Community, Correction, Deliberation,
DeliberationOutcome, DeliberationStatement,
DeliberationVote, Dialect, Encounter, Observation,
Recommendation, Retrospection, Verification, Vocab).
The vendored panproto record types (PanprotoLens,
PanprotoSchema, PanprotoTheory, PanprotoProtolens,
PanprotoProtolensChain, PanprotoComplement,
PanprotoLensAttestation, PanprotoProtocol, plus
PanprotoCommit, PanprotoRefUpdate, PanprotoRepo) are
re-exported at the crate root as their own structs; they are
not variants of AnyRecord (which is scoped to
IdiolectFamily's NSIDs).
Family
RecordFamily is the trait every family implements; the crate
ships IdiolectFamily for dev.idiolect.* and the
OrFamily<F1, F2> composer that recognises every NSID either
side claims. detect_or_family_overlap audits a probe set at
boot so a configuration mistake does not silently shadow the
right-side family.
Typed wrappers
| Type | Format |
|---|---|
AtUri | at-uri |
Did | did |
Nsid | nsid |
Datetime | RFC 3339 |
Uri | URL |
Cid | CID |
Language | BCP 47 |
Each wraps a string with a parser; the parser fires at
deserialize time. Display / as_str / Deref<Target=str> are
uniform.
Vocab graph helpers
VocabGraph is a normalised read-only view over a Vocab
record (graph form, lifted from the legacy tree where present).
VocabRegistry caches multiple graphs by AT-URI for
cross-vocabulary work. The shipped query verbs
(walk_relation on the graph; is_subsumed_by, satisfies,
translate on the registry)
plus the validate walker are documented on docs.rs and in
The vocabulary knowledge graph.
Examples module
idiolect_records::examples::* exports a fixture per record
kind. Each fixture is the deserialised result of the JSON
constant under lexicons/dev/idiolect/examples/<name>.json. The
shipped fixtures cover: adapter, belief, bounty,
community, correction, dialect, encounter,
observation, recommendation, retrospection, verification,
vocab, plus the vendored panproto records (panproto_lens,
panproto_schema, panproto_commit, ...). Use them in tests so
you do not have to hand-roll JSON.
The four deliberation lexicons do not currently ship example fixtures; consumers building deliberation tests construct records directly via the typed structs.
Feature flags
None. The crate is feature-flag-free and has no transport dependencies.
Errors
Decode failures surface as serde_json::Error with a
serde_path_to_error-shaped path. The structured error type for
the family-decode path is DecodeError (re-exported as
idiolect_records::DecodeError). It distinguishes unknown
NSIDs, decode failures, and family-contract violations (where a
family's contains returned true but decode returned None).
idiolect-codegen
Source:
crates/idiolect-codegen/This crate is
publish = false: it is workspace-internal machinery, not a library you depend on. There is no docs.rs page. The authoritative reference is the source above.
Lexicon-driven Rust + TypeScript emitter. Reads
lexicons/dev/idiolect/*.json and the three spec files
(orchestrator-spec/queries.json, observer-spec/methods.json,
verify-spec/runners.json); writes the generated modules under
each downstream crate.
The crate is shipped both as a library (callable from a
downstream emitter) and as a binary (cargo run -p idiolect-codegen).
Binary subcommands
cargo run -p idiolect-codegen invokes the binary. The two
operations:
| Mode | Purpose |
|---|---|
| Default | Emit every generated tree. |
--check | Verify the working tree matches what the default mode would produce. Exits non-zero on drift. |
The check mode is the drift gate. CI runs it on every PR.
Library API
The callable surface is in idiolect_codegen::emit:
#![allow(unused)] fn main() { use idiolect_codegen::emit::{emit_rust, emit_typescript}; use idiolect_codegen::emit::family::{FamilyConfig, idiolect_family}; use idiolect_codegen::lexicon::LexiconDoc; use idiolect_codegen::Example; }
emit_rust(docs, examples, family) and
emit_typescript(docs, examples, family) take pre-loaded
LexiconDoc and Example slices plus a FamilyConfig, and
return Vec<EmittedFile>. Loading the lexicons from disk is the
caller's job; the workspace binary does this through the
idiolect_codegen::lexicon parser.
FamilyConfig carries three Cow<'static, str> fields: the
marker name, the family ID, and the NSID prefix. The shipped
default for dev.idiolect.* is the idiolect_family()
constructor.
What it emits
Per shipped lexicon (lexicons/dev/idiolect/<name>.json):
- A Rust module under
crates/idiolect-records/src/generated/dev/idiolect/<name>.rswith the typed record struct, every nesteddefstype, theRecordimpl, and the open-enum types with their helpers. - A TypeScript module under
packages/schema/src/generated/with the validator, the discriminator predicates, and the'a' | 'b' | (string & {})open-enum types.
Per spec file:
orchestrator-spec/queries.jsonproduces the orchestrator's HTTP routes (crates/idiolect-orchestrator/src/generated/) and the matching CLI dispatcher (crates/idiolect-cli/src/generated.rs).observer-spec/methods.jsonproduces the observer's method taxonomy (crates/idiolect-observer/src/generated.rs).verify-spec/runners.jsonproduces the verifier's runner taxonomy (crates/idiolect-verify/src/generated.rs).
Each spec file is a single JSON document with a top-level
queries / methods / runners array; codegen produces the
dispatch tables and typed enums. The hand-written predicates
live alongside the generated tree.
Drift gate semantics
cargo run -p idiolect-codegen -- --check runs the same emitter
as the default mode, then byte-compares each emitted file
against the working-tree counterpart. Any diff is a drift error,
with a per-file diff. Run cargo run -p idiolect-codegen to
fix.
Identifier policy
Three rules:
- NSIDs are ASCII, lowercase, dot-separated. The emitter rejects non-conforming input.
- PascalCase names are derived deterministically from a slug.
On collision (
foo-barandfoo_bar), the second occurrence gets a numeric suffix (FooBar2). - The emitter walks each record's path until each member's
prefix is unique within the colliding group; the alias is
the unique-prefix concatenation (e.g.
ChangelogEntry,ResourceEntry).
The collision report is printed at codegen time so authors can rename a slug when the generated name is awkward.
idiolect-lens
Source:
crates/idiolect-lens/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-lens --open.
Resolve dev.panproto.schema.lens records and run apply_lens.
Bridges idiolect's record runtime to panproto's lens runtime.
Because the crate is publish = false, downstream consumers
depend on it via a git or path reference rather than a
registry version:
[dependencies]
idiolect-lens = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["pds-reqwest"] }
Public surface
Resolvers
Resolver is the trait every resolver implements. It is
object-safe (Arc<dyn Resolver>) since v0.8.0; the resolve
future is Send.
Shipped implementations:
| Type | Backing store |
|---|---|
InMemoryResolver | HashMap<AtUri, PanprotoLens>. For tests and fixtures. |
PdsResolver<C> | com.atproto.repo.getRecord via a pluggable PdsClient. |
PanprotoVcsResolver<C> | A panproto vcs store via a pluggable PanprotoVcsClient. |
CachingResolver<R> | TTL'd cache wrapping any R: Resolver. |
VerifyingResolver<R, H> | Re-hashes the bytes, refuses on mismatch. |
Schema loaders
SchemaLoader is also object-safe. Shipped implementations:
InMemorySchemaLoader, FilesystemSchemaLoader.
Apply functions
The runtime shipped under idiolect_lens::runtime:
apply_lens/apply_lens_put— state-based forward / backward.apply_lens_get_edit/apply_lens_put_edit— edit-based variants for incremental translation.apply_lens_symmetric— symmetric pairing of two state-based lenses sharing a middle schema.
Each takes a resolver, a schema loader, a Protocol, and a
typed input struct; each returns a typed output struct. The
composed future is Send so callers can spawn it under
tokio::spawn or hold it inside an #[async_trait] impl.
PDS clients
PdsClient (read) and PdsWriter (write) are the boundary
traits over xrpc. Behind feature flags:
| Feature | Adds |
|---|---|
pds-reqwest | ReqwestPdsClient (read-only). The reqwest-backed write surface uses SigningPdsWriter plus a DpopProver (one of StaticDpopProver, NoOpDpopProver, or P256DpopProver with the dpop-p256 feature). |
pds-atrium | AtriumPdsClient. |
pds-resolve | fetcher_for_did, publisher_for_did — DID-to-PDS resolution helpers. Pulls in idiolect-identity. |
dpop-p256 | The P256DpopProver for OAuth-bound DPoP requests. |
Generic publisher
RecordPublisher<W: PdsWriter> is the typed publisher. Wrap any
PdsWriter with RecordPublisher::new(writer, repo_did) and
publish typed records via publisher.create::<R: Record>(&record),
publisher.put, and publisher.delete. The publisher serializes
the record, splices the $type field, and forwards to the
PdsWriter boundary.
Errors
LensError collapses backend-specific errors into a small set
of variants (NotFound, Transport, decode failures, translate
failures). Backend-specific errors collapse to one of these at
the resolver layer; callers do not pattern-match on transport
types.
Composition pattern
The recommended runtime stack:
#![allow(unused)] fn main() { use std::sync::Arc; use std::time::Duration; use idiolect_lens::*; let client = ReqwestPdsClient::with_service_url("https://bsky.social"); let inner: Arc<dyn Resolver> = Arc::new(PdsResolver::new(client)); let verifying = Arc::new(VerifyingResolver::sha256(inner)); let resolver = CachingResolver::new(verifying, Duration::from_secs(300)); let loader = FilesystemSchemaLoader::new("./schema-cache")?; let out = apply_lens(&resolver, &loader, &Protocol::default(), input).await?; }
The Arc<dyn Resolver> indirection lets a downstream
orchestrator inject a different resolver (e.g. a record-of-record
mock for tests) without changing the surface.
idiolect-identity
API reference: docs.rs/idiolect-identity · Source:
crates/idiolect-identity/· Crate: crates.io/idiolect-identityThis page is an editorial overview. The per-symbol surface (every public type, trait, function, and feature flag) is the docs.rs link above; that is the authoritative reference.
DID resolution. Maps a did to a structured DidDocument
carrying the also-known-as set, service entries, verification
methods, and any additional fields the source document carries.
[dependencies]
idiolect-identity = { version = "0.8", features = ["resolver-reqwest"] }
Public surface
IdentityResolver is the trait every resolver implements; the
crate ships three implementations.
| Type | Feature | Backing |
|---|---|---|
InMemoryIdentityResolver | (always) | HashMap<Did, DidDocument>. Tests and fixtures. |
ReqwestIdentityResolver | resolver-reqwest | Reqwest-backed; resolves did:plc via plc.directory and did:web via .well-known/did.json. |
CachingIdentityResolver<R> | (always) | TTL'd cache wrapping any inner resolver. |
DidDocument carries the resolved data. The shipped accessors
include handle(), pds_url(), and the underlying
also_known_as field; see docs.rs for the full surface.
Errors
IdentityError is the single error type the crate exposes.
Variants distinguish transport failures, parse failures, and
unsupported DID methods.
Feature flags
| Feature | Adds |
|---|---|
resolver-reqwest | The ReqwestIdentityResolver implementation. |
Caching
The shipped CachingIdentityResolver wraps any inner resolver
with a TTL'd cache. Default TTL and overrides are documented on
docs.rs. Cache hits skip the HTTP request entirely; cache misses
fall through to the inner resolver. Errors are not cached.
idiolect-indexer
API reference: docs.rs/idiolect-indexer · Source:
crates/idiolect-indexer/· Crate: crates.io/idiolect-indexerThis page is an editorial overview. The per-symbol surface (every public type, trait, function, and feature flag) is the docs.rs link above; that is the authoritative reference.
Firehose consumer factored into three trait surfaces. The crate owns the loop; you bring the stream, the handler, and the cursor store.
[dependencies]
idiolect-indexer = { version = "0.8",
features = ["firehose-jetstream", "cursor-filesystem", "reconnecting"] }
Public surface
Trait surface
#![allow(unused)] fn main() { pub trait EventStream: Send + Sync { async fn next_event(&mut self) -> Result<Option<RawEvent>, IndexerError>; } pub trait CursorStore: Send + Sync { async fn load(&self, subscription_id: &str) -> Result<Option<u64>, IndexerError>; async fn commit(&self, subscription_id: &str, seq: u64) -> Result<(), IndexerError>; async fn list(&self) -> Result<Vec<(String, u64)>, IndexerError> { /* default */ } } pub trait RecordHandler<F: RecordFamily = IdiolectFamily>: Send + Sync { async fn handle(&self, event: &IndexerEvent<F>) -> Result<(), IndexerError>; } }
IndexerEvent<F> carries the decoded event: seq, live, the
DID, repo revision, rkey, NSID, action (create / update /
delete), CID, and the typed record body (Option<F::AnyRecord>).
Composer
#![allow(unused)] fn main() { pub async fn drive_indexer<F, S, H, C>( stream: &mut S, handler: &H, cursor_store: &C, config: &IndexerConfig, ) -> Result<(), IndexerError> where F: RecordFamily, S: EventStream, H: RecordHandler<F>, C: CursorStore; }
drive_idiolect_indexer is the convenience alias when
F = IdiolectFamily.
Shipped impls
| Type | Feature | Purpose |
|---|---|---|
JetstreamEventStream | firehose-jetstream | Subscribes to a Jetstream websocket feed. |
TappedFirehoseStream | firehose-tapped | Subscribes to the at-proto-native firehose via tapped. |
ReconnectingStream<S> | reconnecting | Wraps any S: EventStream with exponential-backoff reconnect. |
InMemoryCursorStore | (always) | HashMap-backed; for tests. |
FilesystemCursorStore | cursor-filesystem | One JSON file per stream. |
SqliteCursorStore | cursor-sqlite | One row per stream. Pairs with handlers that also write SQLite. |
NoopRecordHandler | (always) | Counts events and drops them. Useful as a baseline. |
RetryingHandler / CircuitBreakerHandler | resilience | Wraps an inner handler with retry / circuit-breaker policies. |
Error surface
IndexerError flattens the failure modes from all three
boundaries. Variants:
| Variant | Trigger |
|---|---|
Stream(String) | Transport error from the event stream. |
Cursor(String) | Cursor store read or write failed. |
Decode(DecodeError) | A known NSID failed to decode into its typed record. |
Handler(String) | Handler returned a handler-defined error. |
MissingBody(String) | The firehose event had no record body or the body was malformed. |
FamilyContract(String) | contains accepted an NSID but decode returned None — a family-implementation bug. |
Feature flags
| Feature | Adds |
|---|---|
firehose-jetstream | Jetstream websocket client. |
firehose-tapped | Tapped at-proto-native firehose client. |
cursor-filesystem | Filesystem cursor store. |
cursor-sqlite | SQLite cursor store. |
reconnecting | Reconnect wrapper. |
resilience | Retry and circuit-breaker handler wrappers. |
Cursor commit semantics
drive_indexer commits the cursor only after the handler
returns Ok. A failing handler does not commit; the loop
either retries on the next event (default) or surfaces the
error. For exactly-once semantics, the handler coordinates the
cursor commit with its own storage transaction.
idiolect-oauth
Source:
crates/idiolect-oauth/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-oauth --open.
ATProto OAuth session storage. The crate carries the token-store
trait and shipped implementations; the OAuth dance itself lives
in atrium-oauth-client and the DPoP signer lives in
idiolect-lens under the dpop-p256 feature.
Because the crate is publish = false, depend via git or path:
[dependencies]
idiolect-oauth = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["store-filesystem"] }
Public surface
OAuthTokenStore is the trait every store implements; the
typical surface is get / put / delete keyed by DID.
OAuthSession carries the access token, refresh token, expiry,
and DPoP key. The session has helpers (is_expired,
time_until_expiry, needs_refresh(now, threshold),
refresh_expired) for callers that want to drive their own
refresh policy.
Shipped stores
| Store | Feature | Backing |
|---|---|---|
InMemoryOAuthTokenStore | (always) | HashMap-backed; for tests. |
FilesystemOAuthTokenStore | store-filesystem | One JSON file per session. |
SqliteOAuthTokenStore | store-sqlite | One row per session. |
All three implement OAuthTokenStore. Anything that takes
Arc<dyn OAuthTokenStore> accepts any of them.
Errors
StoreError covers store-side failures; SessionError covers
session-shape failures. Callers that want a flattened error type
build their own at the application boundary.
Feature flags
| Feature | Adds |
|---|---|
store-filesystem | The filesystem-backed session store. |
store-sqlite | The SQLite-backed session store. |
DPoP keys
The session carries a DPoP keypair. Persistence is the store's responsibility; both shipped stores persist it alongside the session. A custom store must do the same; the OAuth RFC requires DPoP keys to survive across requests.
The signer behind the DPoP-bound HTTP layer is P256DpopProver
in idiolect-lens under the dpop-p256 feature. The lens
crate's SigningPdsWriter wraps a DpopProver so every PDS
write sends a DPoP-bound proof header.
idiolect-observer
Source:
crates/idiolect-observer/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-observer --open.
Fold encounter-family records into observation records. Driven
by the declarative spec at observer-spec/methods.json; codegen
emits the method-descriptor table.
Because the crate is publish = false, depend via git or path:
[dependencies]
idiolect-observer = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["daemon"] }
Public surface
#![allow(unused)] fn main() { pub trait ObservationMethod: Send + Sync { fn name(&self) -> &str; fn version(&self) -> &str; /* observe(...) plus snapshot accessors; see source */ } pub trait ObservationPublisher: Send + Sync { /* persist or transmit a finished observation record */ } pub struct ObserverHandler<M, P> { /* RecordHandler<IdiolectFamily> impl wiring an ObservationMethod onto an ObservationPublisher */ } pub async fn drive_observer<S, C, M, P>( stream: &mut S, cursor_store: &C, handler: &ObserverHandler<M, P>, schedule: FlushSchedule, ) -> ObserverResult<()>; }
ObservationMethod is the stateful aggregator. It folds decoded
events into internal state and snapshots that state on flush.
ObserverHandler wires the method onto the indexer's
RecordHandler boundary; drive_observer runs the indexer loop
and triggers periodic flushes that publish observations through
the configured publisher.
Shipped methods
The spec at observer-spec/methods.json declares eight bundled
methods; the typed structs ship in crates/idiolect-observer/src/methods/:
| Spec name | Module | Folds |
|---|---|---|
correction-rate | correction_rate | Per-lens correction counts grouped by reason. |
encounter-throughput | encounter_throughput | Encounter traffic by kind and downstream result. |
verification-coverage | verification_coverage | Per-lens verification counts by kind, result, and distinct verifiers. |
lens-adoption | lens_adoption | Per-lens encounter count and distinct invokers. |
action-distribution | action_distribution | Encounter counts grouped by use.action (with optional vocab roll-up). |
purpose-distribution | purpose_distribution | Encounter counts grouped by use.purpose. |
basis-distribution | basis_distribution | Record counts grouped by basis variant, bucketed by record kind. |
attribution-chains | attribution_chains | Counts of dev.idiolect.belief records by holder and subject. |
Methods come in two forms (declared in the spec):
Record-form methods consume&IndexerEvent<IdiolectFamily>directly and implementObservationMethod.Instance-form methods consume a panprotoWInstanceplus the NSID and implementInstanceMethod. They wrap intoObservationMethodviaInstanceMethodAdapter.
default_methods() returns boxed instances of every
record-form method.
Publisher
ObservationPublisher is the persistence boundary. Shipped
implementations:
| Type | Backing |
|---|---|
InMemoryPublisher | Vec<Observation>. For tests. |
PdsPublisher | Writes via an idiolect_lens::PdsWriter to the observer's PDS. |
Errors
ObserverError is a flattened error type; ObserverResult<T>
is its alias.
Feature flags
| Feature | Adds |
|---|---|
daemon | The idiolect-observer binary plus its CLI. Pulls in tracing-subscriber, anyhow, and the indexer's tapped firehose / sqlite cursor features. |
pds-atrium | Forwards to idiolect-lens/pds-atrium for the PDS publisher. |
Adding a method
Edit observer-spec/methods.json, add the method's entry, run
cargo run -p idiolect-codegen. The generated descriptor table
picks up the new method; you write the
ObservationMethod (or InstanceMethod) impl in
crates/idiolect-observer/src/methods/<module>.rs and add it to
the default_methods() constructor.
idiolect-orchestrator
Source:
crates/idiolect-orchestrator/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-orchestrator --features daemon --open.
Read-only HTTP query API over a record catalog. Driven by
orchestrator-spec/queries.json; codegen emits the routes plus
the matching CLI dispatcher.
Because the crate is publish = false, depend via git or path:
[dependencies]
idiolect-orchestrator = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0", features = ["daemon", "catalog-sqlite", "query-http"] }
Public surface
The crate exposes:
Catalog— an in-memory struct holdingEntry<R>slots per record kind, with typed iterators (encounters(),bounties(),verifications(), ...).CatalogRef— a shareable handle around the catalog that the HTTP handlers and the indexer's record handler both hold.SqliteCatalogStore(undercatalog-sqlite) — persistent catalog backing.CatalogHandler— the indexer'sRecordHandler<IdiolectFamily>impl that upserts every accepted record into the catalog.AppStateplushttp_router()— axum router wiring underquery-http.- Theory-resolver and predicate-evaluator helpers used by generated query handlers.
HTTP endpoints
Every handler under the v1 prefix is generated from
orchestrator-spec/queries.json. The current shipped routes:
| Path | Returns |
|---|---|
GET /healthz, GET /readyz | Liveness + readiness. |
GET /metrics | Prometheus exposition. |
GET /v1/stats | Per-kind record counts. |
GET /v1/bounties/open | Cataloged bounties whose status is open / claimed / unset. |
GET /v1/bounties/want-lens?... | Bounties whose wants is a specific lens. |
GET /v1/bounties/by-requester?requester_did=... | Bounties by requester. |
GET /v1/adapters?framework=... | Adapters by framework. |
GET /v1/adapters/by-invocation-protocol?... | Adapters by invocation-protocol kind. |
GET /v1/adapters/with-verification?... | Adapters with at least one verification record. |
GET /v1/recommendations | Recommendations starting from a given source schema. |
GET /v1/verifications?lens_uri=... | Verifications for a specific lens. |
GET /v1/verifications/by-kind?... | Verifications by kind. |
GET /v1/communities?... | Communities for a member DID. |
GET /v1/communities/by-name?... | Communities by name. |
GET /v1/dialects/for-community?... | Dialects owned by a community. |
GET /v1/beliefs/about?... | Beliefs whose subject is a given record. |
GET /v1/beliefs/by-holder?... | Beliefs by holder DID. |
GET /v1/vocabularies/by-world?... | Vocabularies declared with a given world. |
GET /v1/vocabularies/by-name?... | Vocabularies by name. |
The full path-and-flag table for each endpoint is generated; see
orchestrator-spec/queries.json
for the authoritative list.
Errors
OrchestratorError flattens catalog and HTTP errors;
OrchestratorResult<T> is its alias.
Feature flags
| Feature | Adds |
|---|---|
catalog-sqlite | SQLite-backed catalog store. |
query-http | HTTP server (axum-based). |
daemon | The idiolect-orchestrator binary, wiring the indexer plus catalog plus HTTP API with a tapped-backed firehose. |
Observability
/metrics exposes Prometheus counters and histograms for the
catalog and per-endpoint latency. Structured tracing logs at
info level for accepted requests; debug for query
internals. The exact metric names are defined in
crates/idiolect-orchestrator/src/http.rs.
idiolect-verify
Source:
crates/idiolect-verify/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-verify --open.
Verification runners with declarative dispatch. Driven by
verify-spec/runners.json; codegen emits the kind taxonomy.
Because the crate is publish = false, depend via git or path:
[dependencies]
idiolect-verify = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0" }
Public surface
#![allow(unused)] fn main() { pub trait VerificationRunner: Send + Sync { fn kind(&self) -> VerificationKind; fn tool(&self) -> Tool; async fn run(&self, target: &VerificationTarget) -> VerifyResult<Verification>; } }
A runner returns a Verification record with result set to
Holds, Falsified, or Inconclusive. Falsification is not
an error: a falsified verification is a first-class record.
VerifyError is reserved for input-shape, transport, or
irrecoverable-state failures.
The build_verification helper packages a runner result into a
Verification record shaped for direct publication via
idiolect_lens::RecordPublisher::create.
Shipped runners
The spec at verify-spec/runners.json declares four bundled
kinds:
| Kind | Runner |
|---|---|
roundtrip-test | RoundtripTestRunner — runs put(get(a)) == a over a corpus. |
property-test | PropertyTestRunner — runs an arbitrary boolean predicate over a corpus. |
static-check | StaticCheckRunner — runs panproto's existence and structural checks against the lens chain. |
coercion-law | CoercionLawRunner — runs panproto's sample-based coercion-law checker, optionally via a CoercionLawClient. |
The lexicon's verification.kind field is open-enum and lists
additional kinds (formal-proof, conformance-test,
convergence-preserving); those kinds are recognised but not
shipped as runners. Communities that need them author their own
runner against the trait.
Errors
VerifyError covers input-shape, transport, and
irrecoverable-state failures; VerifyResult<T> is its alias.
Result records
A passing run produces a Verification record with
result: "holds". A falsifying run produces one with
result: "falsified" plus a counterexample (when the runner
captured one). The publisher path uses
idiolect_lens::RecordPublisher.
Adding a runner
- Add the kind's entry to
verify-spec/runners.json. - Run
cargo run -p idiolect-codegento refresh the generated kind taxonomy. - Implement the
VerificationRunnertrait against the new kind in a new module undercrates/idiolect-verify/src/. - Re-export it from
lib.rsand add to the runner registry wiring.
idiolect-migrate
Source:
crates/idiolect-migrate/This crate is
publish = falseand is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally withcargo doc -p idiolect-migrate --open.
Schema-diff classification plus lens-based record migration.
Thin typed façade over panproto-check (for diff classification)
and idiolect-lens (for record translation).
Because the crate is publish = false, depend via git or path:
[dependencies]
idiolect-migrate = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.8.0" }
Public surface
The crate exposes:
classify(src, tgt)— runs the panproto diff and returns aCompatReportdistinguishing compatible from breaking changes.plan_auto(src, tgt, hints)— for breaking diffs that are covered by shipped migration recipes, returns aMigrationPlancarrying source / target schema hashes plus a lens body the caller can publish. For breaking diffs that resist automation, returnsErr(PlannerError::NotAutoDerivable)listing the offending changes.migrate_record(lens, source_record, schema_loader)— wrapsidiolect_lens::apply_lensfor the one-shot case.MigrationPlan— the typed plan struct.MigrateError,MigrateResult,PlannerError— the error types.- Re-exported
CompatReportandSchemaDifffrompanproto-checkfor convenience.
Migration shapes
| Diff | Behavior |
|---|---|
| Non-breaking (added optional, added vertex, added edge) | classify returns compatible = true; no plan is needed. |
| Auto-derivable breaking (removed optional, renamed vertex via hint) | plan_auto returns a MigrationPlan with a protolens-chain body. |
| Non-auto breaking (removed required, changed required type, added required without default) | plan_auto returns NotAutoDerivable. The caller writes the lens by hand. |
Why this is a separate crate from idiolect-lens
Two reasons:
- The migration-shaped API (classify-then-plan-then-migrate)
is a different shape than the runtime API
(
apply_lensplus resolvers). idiolect-migratedepends onpanproto-check, which is a heavier dep than the lens runtime itself. Keeping it separate keeps the runtime crate's compile-time small.
Scope
The crate owns no runtime state. It is a thin façade; the
runtime cost of a migration equals the cost of one apply_lens
per record.
idiolect-cli
Source:
crates/idiolect-cli/This crate is
publish = falseand is not on docs.rs. The CLI is intended to be installed and run, not depended on as a library.
The idiolect command-line tool. Wraps the library crates so
operators and end users do not need to write Rust for common
operations.
cargo install --path crates/idiolect-cli
The CLI hardcodes the idiolect-lens features it needs
(pds-reqwest, pds-resolve); there are no CLI-level features
to set.
Subcommand surface
idiolect resolve <did>
idiolect fetch <at-uri>
idiolect orchestrator <subcommand>
idiolect encounter record [...]
idiolect version
idiolect help
The full reference is the CLI reference.
Why no clap
The CLI uses a hand-rolled subcommand parser. Two reasons:
- The orchestrator subcommand surface is partly codegen-emitted
from
orchestrator-spec/queries.json. A clap surface would put two layers of declarative wiring on top of each other. - Compile time and binary size matter for a tool meant to be installed broadly.
Codegen surface
The CLI's orchestrator … dispatcher is emitted from
orchestrator-spec/queries.json into
crates/idiolect-cli/src/generated.rs. Adding a query (per the
orchestrator guide) regenerates
the dispatcher; the new subcommand becomes available
automatically.
The hand-written subcommands (resolve, fetch,
encounter record, version, help) live in main.rs and
encounter.rs.
Output
All commands print pretty-printed JSON to stdout on success.
Errors go to stderr with error: <message>.
Configuration
| Setting | Default | Override |
|---|---|---|
| Orchestrator URL | http://localhost:8787 | --url flag on orchestrator subcommands |
| Log level | info | RUST_LOG env var |
The CLI does not read a config file.
Lexicons
The dev.idiolect.* lexicon family. Every record kind that travels
on the network has a lexicon document under
lexicons/dev/idiolect/.
| NSID | Page |
|---|---|
dev.idiolect.adapter | adapter |
dev.idiolect.belief | belief |
dev.idiolect.bounty | bounty |
dev.idiolect.community | community |
dev.idiolect.correction | correction |
dev.idiolect.defs | defs |
dev.idiolect.deliberation | deliberation |
dev.idiolect.deliberationStatement | deliberationStatement |
dev.idiolect.deliberationVote | deliberationVote |
dev.idiolect.deliberationOutcome | deliberationOutcome |
dev.idiolect.dialect | dialect |
dev.idiolect.encounter | encounter |
dev.idiolect.observation | observation |
dev.idiolect.recommendation | recommendation |
dev.idiolect.retrospection | retrospection |
dev.idiolect.verification | verification |
dev.idiolect.vocab | vocab |
Policy
The pages in this section are navigation summaries. The
authoritative shape for every lexicon is the JSON document under
lexicons/dev/idiolect/<name>.json; the generated Rust types on
docs.rs/idiolect-records
are derived from that JSON and are the authoritative typed
surface. When this book and either source disagree, the source
wins; please file an issue.
dev.idiolect.adapter
A subprocess or HTTP-endpoint wrapper for a framework's tooling, authored by incentive-aligned parties. Adapters are how idiolect glues existing frameworks (Hasura, Prisma, Datomic, FHIR, Coq, Meilisearch, ...) without forking them. The substrate publishes the adapter declaration; the orchestrator runs the adapter under the declared isolation policy.
Source:
lexicons/dev/idiolect/adapter.json· Rust:idiolect_records::Adapter· TS:@idiolect-dev/schema/adapter· Fixture:idiolect_records::examples::adapter
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
framework | string (≤128) | yes | Canonical framework name (e.g. hasura, prisma, coq). |
versionRange | string | yes | Semver range supported. |
invocationProtocol | object | yes | How the adapter is invoked. |
isolation | object | yes | Sandboxing requirements the orchestrator must honour. |
author | did | yes | DID of the adapter author. |
verification | at-uri | no | Optional verification record demonstrating conformance. |
occurredAt | datetime | yes | Publication timestamp. |
invocationProtocol
| Subfield | Type | Required | Notes |
|---|---|---|---|
kind | open enum | yes | subprocess / http / wasm. |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
entryPoint | string | no | Binary name (subprocess), URL (http), or WASM module reference. |
inputSchema | schemaRef | no | Schema of the adapter's input. |
outputSchema | schemaRef | no | Schema of the adapter's output. |
isolation
| Subfield | Type | Required | Notes |
|---|---|---|---|
kind | open enum | yes | none / process / container / vm / wasm-sandbox. |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
networkPolicy | open enum | no | none / egress-denylist / egress-allowlist / full. |
networkPolicyVocab | vocabRef | no | Vocab the policy slug resolves against. |
filesystemPolicy | open enum | no | readonly / scratch / writable-subtree / full. |
filesystemPolicyVocab | vocabRef | no | Vocab the policy slug resolves against. |
resourceLimits | { maxMemoryBytes?, maxCpuSeconds?, maxWallSeconds? } | no | Hard ceilings the orchestrator enforces. |
Field details
Why an adapter is a record
An adapter is a declared contract: the publisher asserts that "this framework, at this version range, can be invoked via this protocol under this isolation policy". The orchestrator running the adapter trusts the contract only as far as it trusts the publisher's signature; verification records can pin specific conformance claims.
The alternative (each orchestrator hand-coding adapter wrappers per framework) does not scale. The adapter record is the declarative replacement: a community with framework expertise publishes the wrapper once; orchestrators pick it up from the network.
invocationProtocol.kind
The transport over which the orchestrator drives the adapter:
| Slug | What it means |
|---|---|
subprocess | The orchestrator forks entryPoint as a child process and pipes JSON over stdin/stdout. |
http | The orchestrator POSTs JSON to the URL at entryPoint. |
wasm | The orchestrator instantiates the WASM module at entryPoint and calls a designated export. |
The slug is open-enum: a community publishing a vocab with an
additional kind (e.g. nats-rpc, grpc-stream) extends the
transport set without modifying the lexicon.
isolation.kind
The sandboxing posture the orchestrator must honour:
| Slug | What it means |
|---|---|
none | Run in the orchestrator's own process. Only safe for fully-trusted code. |
process | Fork into a separate process; OS-level isolation. |
container | Run in a container (Docker, Podman, Firecracker microVM). |
vm | Run in a full VM. |
wasm-sandbox | Run in a WASM runtime with capability-based access. |
The orchestrator's policy is to refuse any adapter whose
isolation.kind is weaker than its configured floor. An
orchestrator configured for container minimum will not run an
adapter declaring process.
Network and filesystem policies
Orthogonal axes layered on top of the kind:
networkPolicy | What it means |
|---|---|
none | No network access. |
egress-denylist | Network access except to listed denied hosts. |
egress-allowlist | Network access only to listed allowed hosts. |
full | Unrestricted. |
filesystemPolicy | What it means |
|---|---|
readonly | The adapter sees a read-only mount. |
scratch | The adapter writes to a scratch directory cleaned up after each invocation. |
writable-subtree | The adapter writes to a designated subtree. |
full | Unrestricted. |
The orchestrator's enforcement is best-effort and depends on the
underlying isolation runtime; e.g. wasm-sandbox makes
egress-allowlist cheap and exact, process makes it harder.
resourceLimits
Hard ceilings. The orchestrator kills the adapter if it exceeds any of:
| Field | Unit |
|---|---|
maxMemoryBytes | RAM, in bytes. |
maxCpuSeconds | CPU time, in seconds. |
maxWallSeconds | Wall-clock time, in seconds. |
A consumer running an untrusted adapter sets all three.
verification
An optional pointer to a dev.idiolect.verification record
demonstrating conformance. A consumer that wants to trust an
adapter's claim about its inputSchema / outputSchema looks for
a conformance-test verification (see
verification).
Example
{
"$type": "dev.idiolect.adapter",
"framework": "hasura",
"versionRange": "^2.30",
"invocationProtocol": {
"kind": "http",
"entryPoint": "https://hasura.example/v1/graphql",
"inputSchema": { "uri": "at://did:plc:adapter-author/dev.panproto.schema.schema/hasura-input" },
"outputSchema": { "uri": "at://did:plc:adapter-author/dev.panproto.schema.schema/hasura-output" }
},
"isolation": {
"kind": "container",
"networkPolicy": "egress-allowlist",
"filesystemPolicy": "scratch",
"resourceLimits": {
"maxMemoryBytes": 1073741824,
"maxCpuSeconds": 30,
"maxWallSeconds": 60
}
},
"author": "did:plc:adapter-author",
"occurredAt": "2026-04-19T00:00:00.000Z"
}
Concept references
- Concepts: The dev.idiolect.* lexicon family
- Lexicons: verification · bounty (
wantAdapter)
dev.idiolect.belief
A signed doxastic claim that a referenced record is true or
applicable. Belief records are how third parties represent what
they think about another record without flattening provenance:
the subject strong-ref pins the exact referenced record, the
holder names the party whose attitude is represented, and the
basis names what grounds it.
Source:
lexicons/dev/idiolect/belief.json· Rust:idiolect_records::Belief· TS:@idiolect-dev/schema/belief· Fixture:idiolect_records::examples::belief
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
subject | strongRecordRef | yes | AT-URI + CID for the record the belief is about. |
holder | did | no | Party whose attitude is represented. Omit for first-party. |
basis | basis | no | Structured grounding (load-bearing when holder differs from the repo owner). |
annotations | string (≤4000 graphemes) | no | Narrative commentary. |
visibility | visibility | no | Visibility scope. |
occurredAt | datetime | yes | When the belief was published. |
Field details
subject is a strong reference
subject carries both the AT-URI and the CID of the record the
belief is about. Pinning by CID means a later revision of the
target record does not silently change what this belief asserts.
A consumer reading a belief about a recommendation can fetch the
exact recommendation revision the holder believed in, even if the
recommendation has been edited since.
holder versus the repo owner
The simplest case: the repo owner is the holder. The record
expresses the publisher's own belief; holder is omitted.
The richer case: the repo owner is publishing a belief on behalf
of, or about, another party. A labeler that publishes
{ subject: <some encounter>, holder: did:plc:other-party }
is asserting that the other party believes the encounter is
applicable. The labeler's signature attests that the labeler
made the attribution; the holder field carries who the attitude
is attributed to.
basis carries the grounds
When the holder is not the repo owner, the consumer needs to know
on what grounds the attribution rests. The four basis variants:
| Variant | Use when |
|---|---|
basisSelfAsserted | The holder asserted directly with no external grounding claimed. The default when basis is omitted. |
basisCommunityPolicy | Grounded in a community's published policy. |
basisExternalSignal | Grounded in something outside ATProto (a license, an external standard, a statement on another network). |
basisDerivedFromRecord | Grounded in another ATProto record (with an inference rule naming the derivation). |
See defs#basis for the field shapes.
Use as a labeler primitive
A labeler workflow that wants to record "a third party endorses this lens for a particular use" uses three records:
- The lens record (in the lens author's PDS).
- The encounter or recommendation describing the use (in either the labeler's or the third party's PDS).
- A belief record (in the labeler's PDS) with
subjectpointing at the encounter / recommendation,holderset to the third party's DID, andbasiscarrying the structured grounds.
Consumers reading the belief see all three. The labeler's signature on the belief is what makes the attribution accountable.
Example
{
"$type": "dev.idiolect.belief",
"subject": {
"uri": "at://did:plc:other-party/dev.idiolect.recommendation/3l5",
"cid": "bafy..."
},
"holder": "did:plc:other-party",
"basis": {
"$type": "dev.idiolect.defs#basisCommunityPolicy",
"community": "at://did:plc:community/dev.idiolect.community/canonical",
"policyUri": "https://community.example/policies/lens-endorsement-v1"
},
"annotations": "Labeler attests the holder accepted this recommendation.",
"visibility": "public-detailed",
"occurredAt": "2026-04-19T00:00:00.000Z"
}
How beliefs compose
flowchart LR
L[labeler] -->|publishes| B[belief]
B -->|subject| R[recommendation]
B -->|holder| H[holder DID]
B -->|basis| G[community policy]
R -->|signed by| C[community]
C -.recognised by.-> H
A consumer reading the belief sees: which record is being
endorsed (subject), who is doing the endorsing (holder), on
what grounds (basis), and who is attributing it (the repo
signer). Disagreements among labelers about a holder's beliefs
are themselves first-class records: a contradicting belief from
a different labeler is just another record, signed and visible.
Concept references
- Concepts: The dev.idiolect.* lexicon family
- Concepts: Records as content-addressed signed data
- Lexicons: defs (
#basis,#strongRecordRef) · recommendation
dev.idiolect.bounty
A declaration that a translation, verification, or adapter is wanted, with terms. The substrate does not intermediate fulfillment: payment, review, and acceptance happen on external rails referenced in the record. The record is the request primitive.
Source:
lexicons/dev/idiolect/bounty.json· Rust:idiolect_records::Bounty· TS:@idiolect-dev/schema/bounty· Fixture:idiolect_records::examples::bounty
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
requester | did | yes | Who is requesting. |
wants | union | yes | Exactly one of wantLens / wantVerification / wantAdapter. |
constraints | array (≤64) | no | Structured constraints the deliverable must satisfy. |
reward | object | no | { summary?, externalRef? }. The substrate does not transact. |
eligibility | array (≤128) | no | Postfix eligibility tree. |
fulfillment | at-uri | no | Once fulfilled, points to the deliverable record. |
status | open enum | no | open / claimed / fulfilled / withdrawn. |
statusVocab | vocabRef | no | Vocab the status slug resolves against. |
basis | basis | no | Structured grounding. |
occurredAt | datetime | yes | Publication timestamp. |
The three want shapes
wantLens
Asks for a lens between two schemas.
| Subfield | Type | Required | Notes |
|---|---|---|---|
source | schemaRef | yes | Source schema. |
target | schemaRef | yes | Target schema. |
bidirectional | boolean | no | Whether the requested lens must be invertible. |
wantVerification
Asks for a verification of a lens.
| Subfield | Type | Required | Notes |
|---|---|---|---|
lens | lensRef | yes | The lens to verify. |
kind | open enum | yes | Verification kind: roundtrip-test / property-test / formal-proof / conformance-test / static-check / convergence-preserving. (Note: the lexicon's known-values list here omits coercion-law; that kind is reachable through the open-enum extension via kindVocab.) |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
wantAdapter
Asks for an adapter for a framework.
| Subfield | Type | Required | Notes |
|---|---|---|---|
framework | string (≤128) | yes | Framework name. |
versionRange | string | no | Semver range. |
The constraint variants
Each entry in constraints is one of:
| Variant | Captures |
|---|---|
constraintPerformance | A quantitative bound: metric, threshold, comparison direction (lt, le, eq, ge, gt), optional sample size. Examples: p99-latency-ms ≤ 50, error-rate < 0.001. |
constraintConformance | A verification kind (and optional specific property) the deliverable must pass. |
constraintLicense | An SPDX expression plus optional allow / deny lists. |
constraintDeadline | A datetime deadline plus optional grace seconds. |
constraintDependency | A pointer to another bounty this one waits on. Claims are ineligible until the dependency's status is fulfilled. |
A consumer matching a candidate deliverable against a bounty walks the constraint list and verifies each one. Failing constraints are surfaced individually so the claimer can tell what is missing.
The eligibility tree
eligibility is a postfix-operator tree (same shape as
recommendation conditions). Atomic predicates plus combinators:
| Variant | Arity | Meaning |
|---|---|---|
eligibilityMember | atomic | Claimer is a member of the named community. |
eligibilityVerificationFor | atomic | Claimer has published a verification for the named lens property. |
eligibilityDid | atomic | Claimer's DID matches exactly. |
eligibilityAnd | combinator | Conjoin top two on stack. |
eligibilityOr | combinator | Disjoin top two on stack. |
eligibilityNot | combinator | Negate top on stack. |
A bounty restricted to a specific community uses
[eligibilityMember(community=...)]. A bounty open to either of
two communities uses
[eligibilityMember(A), eligibilityMember(B), eligibilityOr].
Empty array means no eligibility restriction.
Field details
reward
reward is intentionally underspecified. summary is narrative
prose; externalRef is a URL pointing at the rail that handles
the actual reward (a grant portal, a payment platform, an
attestation service). The substrate does not validate that the
external rail exists, that it is solvent, or that the reward will
be paid; that is the consumer's diligence.
The pattern: a bounty with externalRef pointing at a known
grant portal is more credible than a bounty with only a narrative
summary. Consumers route their effort accordingly.
fulfillment
Once a deliverable exists, the bounty publisher edits the bounty
record (a put, not a new record) to set fulfillment to the
deliverable's at-uri and status to fulfilled. Consumers
querying open bounties filter on status: open; consumers
auditing the fulfilled set filter on status: fulfilled and
follow fulfillment.
basis
For first-party bounties (the repo owner is the requester), omit.
For third-party attribution (a labeler records that someone else
is requesting), set basis and requester accordingly. The
common case is basisCommunityPolicy (a community has a standing
policy of requesting verifications of every published lens) or
basisDerivedFromRecord (a researcher infers a request from a
prior recommendation that named required verifications).
Example
{
"$type": "dev.idiolect.bounty",
"requester": "did:plc:requester",
"wants": {
"$type": "dev.idiolect.bounty#wantVerification",
"lens": { "uri": "at://did:plc:lens-author/dev.panproto.schema.lens/3l5" },
"kind": "roundtrip-test"
},
"constraints": [
{ "$type": "dev.idiolect.bounty#constraintConformance",
"kind": "roundtrip-test",
"property": {
"$type": "dev.idiolect.defs#lpRoundtrip",
"domain": "all valid v1 records with bodies ≤ 1024 bytes"
}
},
{ "$type": "dev.idiolect.bounty#constraintDeadline",
"deadline": "2026-06-19T00:00:00.000Z"
}
],
"reward": {
"summary": "USD 500 paid via XYZ grants portal on acceptance.",
"externalRef": "https://grants.example/bounties/3l5"
},
"eligibility": [
{ "$type": "dev.idiolect.bounty#eligibilityMember",
"community": "at://did:plc:community/dev.idiolect.community/canonical" }
],
"status": "open",
"occurredAt": "2026-04-19T00:00:00.000Z"
}
How bounties drive verification work
flowchart LR
R[requester] -->|publishes| B[bounty]
B -->|wants verification| L[lens]
B -->|eligibility| E[claimer]
E -->|publishes| V[verification]
R -->|edits bounty| F[status: fulfilled]
F -.points at.-> V
A community publishing recommendations with requiredVerifications
that nobody has published is asking for verification work. A
bounty is the canonical way to request that work. A claimer that
matches the eligibility predicate publishes the verification, the
requester points the bounty at it, and the external rail handles
the payment.
Concept references
dev.idiolect.community
A group of DIDs that declare shared conventions. Self-constituted: there is no central roll and no grading of legitimacy. Communities may be small and many.
Source:
lexicons/dev/idiolect/community.json· Rust:idiolect_records::Community· TS:@idiolect-dev/schema/community· Fixture:idiolect_records::examples::community
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
name | string (≤128) | yes | Human-readable community name. |
description | string (≤2000 graphemes) | yes | Purpose, norms, scope. Narrative. |
members | array of did (≤500) | no | Inline membership for small communities. |
roleAssignments | array of roleAssignment (≤500) | no | Sparse role assignments where the role differs from the default. |
memberRoleVocab | vocabRef | no | Vocab the role slugs resolve against. |
recordHosting | open enum | no | member-hosted / community-hosted / hybrid. |
appviewEndpoint | uri | no | URL of the community AppView when recordHosting is non-default. |
membershipRoll | at-uri | no | External membership record (for communities above ~200 members). |
coreSchemas | array of schemaRef | no | Schemas the community treats as canonical. |
coreLenses | array of lensRef | no | Lenses the community treats as canonical. |
endorsedCommunities | array of at-uri | no | Other communities recognised as legitimate interlocutors. Not transitive. |
conventions | array (≤64) of structured convention variants | no | Decidable subset of community conventions. |
conventionsText | string (≤10000 graphemes) | no | Narrative conventions: style guides, norms not expressible structurally. |
createdAt | datetime | yes | Publication timestamp. |
roleAssignment
| Subfield | Type | Required | Notes |
|---|---|---|---|
did | did | yes | DID of the member. |
role | open enum | yes | member / moderator / delegate / author. |
A DID may appear multiple times when the role vocabulary supports multiple roles per member.
The convention variants
Each entry in conventions is one of three shapes:
| Variant | Captures |
|---|---|
conventionReviewCadence | Maximum business-days expected before a review is posted, with optional scope narrowing. |
conventionVerificationReq | A verification kind (and optionally a specific property) the community requires before endorsing a lens. |
conventionDeprecationPolicy | Minimum deprecation notice in days; whether deprecations require a replacement pointer. |
The structured subset is what consumers can match on
mechanically. Style guides, tone, and norms not expressible as a
predicate live in conventionsText.
Field details
members versus membershipRoll
Two ways to represent membership:
- Inline
membersis appropriate for small communities. The list lives directly on the community record; reading the community gives you the membership in one fetch. Capped at 500 entries. - External
membershipRollis a pointer to a separate record that maintains the roll. Appropriate for larger communities (above ~200 members) where the roll is updated frequently and shouldn't bloat the community record itself.
A community may use both for the transition period while moving from inline to external; consumers union the two sets.
roleAssignments versus the default role
The default role (named on the role vocabulary's top node) applies
to every member who does not have an explicit roleAssignment.
Only members whose role differs from the default need an entry.
A 500-member community with five moderators carries five
roleAssignment entries, not five hundred.
The shipped default vocabulary seeds member (top), moderator,
delegate, author. A community extends by referencing a custom
memberRoleVocab with additional roles.
recordHosting
| Slug | What it means |
|---|---|
member-hosted | Records live on individual member PDSes (default ATProto). |
community-hosted | Records live on a community AppView, gated by membership (Acorn-style). |
hybrid | Both. Some records are member-hosted, others are AppView-hosted. |
Consumers crawling for community records use this to choose a
surface. A community that publishes community-hosted plus an
appviewEndpoint is telling consumers to route XRPC reads
through the AppView instead of crawling member PDSes.
endorsedCommunities
A community names other communities it recognises as legitimate interlocutors. The endorsement is not transitive: A endorsing B and B endorsing C does not imply A endorsing C. The substrate records the assertion; consumers decide what to do with it. Common patterns: a quorum policy that requires endorsements from trusted communities; a denylist that excludes communities not endorsed by any trusted party.
Example
{
"$type": "dev.idiolect.community",
"name": "tutorial",
"description": "Tutorial community for the idiolect documentation.",
"members": [
"did:plc:alice", "did:plc:bob", "did:plc:carol"
],
"roleAssignments": [
{ "did": "did:plc:alice", "role": "moderator" }
],
"recordHosting": "member-hosted",
"coreSchemas": [
{ "uri": "at://did:plc:tutorial/dev.panproto.schema.schema/post-v1" }
],
"conventions": [
{
"$type": "dev.idiolect.community#conventionVerificationReq",
"kind": "roundtrip-test"
},
{
"$type": "dev.idiolect.community#conventionDeprecationPolicy",
"noticePeriodDays": 90,
"replacementRequired": true
}
],
"conventionsText": "Lens authors review within five business days. Style: terse, factual.",
"createdAt": "2026-04-19T00:00:00.000Z"
}
Concept references
dev.idiolect.correction
A signed record of a post-translation edit. Corrections are the primary signal an observer uses to detect lens quality issues; the reason taxonomy decouples "lens was wrong" from "the world is complicated".
Source:
lexicons/dev/idiolect/correction.json· Rust:idiolect_records::Correction· TS:@idiolect-dev/schema/correction· Fixture:idiolect_records::examples::correction
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
encounter | encounterRef | yes | The encounter whose output was edited. |
path | string (≤1024) | yes | JSON Pointer or equivalent into the produced output. |
originalValue | unknown | no | Value prior to correction. May be elided for visibility reasons. |
correctedValue | unknown | no | Value after correction. |
reason | open enum | yes | lens-error / domain-difference / source-error / downstream-idiosyncrasy / user-mistake / retrospective. |
reasonVocab | vocabRef | no | Vocab the reason slug resolves against. |
rationale | string (≤2000 graphemes) | no | Human-readable justification. |
holder | did | no | Party the correction is attributed to. |
basis | basis | no | Structured grounding for third-party attribution. |
visibility | visibility | yes | Visibility scope. |
occurredAt | datetime | yes | When the correction was made. |
Field details
Why a reason taxonomy
Aggregating corrections naively gives the wrong signal: a lens that produces correct output for half its inputs and domain-different output for the other half is not a buggy lens. The reason taxonomy distinguishes:
| Slug | What it means | Implication for the lens |
|---|---|---|
lens-error | The lens produced wrong output for the input. | Bug in the lens. |
domain-difference | The lens output is correct under one set of conventions; the consumer wants a different set. | Not a bug; a different community translation. |
source-error | The source record was wrong; the lens propagated the error faithfully. | Not a bug; upstream issue. |
downstream-idiosyncrasy | The downstream consumer has an unusual requirement the lens does not target. | Not a bug; consumer-specific. |
user-mistake | The user invoked the wrong lens. | Not a bug; routing issue. |
retrospective | A delayed finding caused by something other than the above; usually escalates to a dev.idiolect.retrospection. | Possibly a bug; investigation pending. |
Observers fold corrections by reason. A high lens-error rate
is signal; a high domain-difference rate is a community
disagreement. Conflating them is the failure mode the taxonomy
exists to prevent.
path
JSON Pointer (RFC 6901) into the produced output. A path of
/body/text identifies the text field under body. Multi-part
paths are supported up to 1024 bytes. Consumers replaying
corrections apply the edit at the named path.
originalValue may be elided
When the encounter's visibility restricts publishing source
data, the correction may carry only correctedValue and a
narrative rationale. A consumer that trusts the corrector can use
the corrected value verbatim; one that wants to verify the
correction needs to fetch the source separately.
Holder versus the repo owner
Most corrections are first-party: the consumer that received the
output is the same as the consumer that edited it. Some are
third-party: a reviewer transcribing an off-network correction.
holder plus basis carry the third-party attribution
machinery, identical to encounter and belief.
Example
{
"$type": "dev.idiolect.correction",
"encounter": {
"uri": "at://did:plc:user/dev.idiolect.encounter/3l5",
"cid": "bafy..."
},
"path": "/body/text",
"originalValue": "the quick brown foxes",
"correctedValue": "the quick brown fox",
"reason": "lens-error",
"rationale": "Lens incorrectly pluralised the singular noun.",
"visibility": "public-detailed",
"occurredAt": "2026-04-19T00:00:00.000Z"
}
How corrections feed observation
flowchart LR
ENC[encounter] -->|downstreamResult: corrected| COR[correction]
COR -->|fold by reason| OBS[observation]
OBS -.consumed by.-> CON[consumer]
CON -->|decides| INVOKE[whether to invoke]
A high lens-error count in observations is a signal. A high
domain-difference count is just a record of community
disagreement. Consumers reading observations make routing
decisions on the former, not the latter; observers therefore
must publish the breakdown by reason, not just a flat correction
count.
Concept references
- Concepts: The dev.idiolect.* lexicon family
- Concepts: Observer protocol
- Lexicons: encounter · observation · retrospection
dev.idiolect.defs
Shared types for the dev.idiolect.* lexicon family. Two kinds of
content live here:
- Cross-cutting reference shapes — lens, schema, encounter, vocab, and strong-record references; tool identity; visibility.
- Content-theory types — purpose, lens property, evidence, caveat, basis. Shared across multiple records.
Record-specific combinator trees (condition, eligibility, constraint, convention) live in their respective record lexicons, not here.
Source:
lexicons/dev/idiolect/defs.json· Rust:idiolect_records::generated::defs· TS:@idiolect-dev/schema/defs
Reference shapes
schemaRef
Reference to a schema. Either an at-uri or a content hash; at least one must be present.
| Subfield | Type | Notes |
|---|---|---|
uri | at-uri | AT-URI pointing to a schema record. |
cid | cid-link | Content hash of the schema. |
language | string | Schema-language identifier (atproto-lexicon, postgres-sql, protobuf, graphql, json-schema). |
lensRef
| Subfield | Type | Notes |
|---|---|---|
uri | at-uri | AT-URI of a lens record. |
cid | cid-link | Content hash of the lens. |
direction | enum | unidirectional / bidirectional. |
encounterRef
| Subfield | Type | Notes |
|---|---|---|
uri | at-uri (required) | AT-URI of the encounter. |
cid | cid-link | Optional CID for revision pinning. |
vocabRef
| Subfield | Type | Notes |
|---|---|---|
uri | at-uri | AT-URI of a vocab record. |
cid | cid-link | Content hash pinning a specific vocab revision. |
strongRecordRef
| Subfield | Type | Required | Notes |
|---|---|---|---|
uri | at-uri | yes | AT-URI of the referenced record. |
cid | cid-link | yes | Content hash. |
Parallel to com.atproto.repo.strongRef. Repeated here so the
defs tree is self-contained.
tool
| Subfield | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Canonical tool name (panproto, coq, tlaplus, z3, nextest). |
version | string | yes | Version string. |
commit | string | no | Optional source commit or build identifier. |
visibility
A closed-enum string. Five values:
| Value | Meaning |
|---|---|
public-detailed | Full record body published. |
public-minimal | Record published with elided detail (e.g. omits source instance). |
public-aggregate-only | Record consumed only by aggregators; individual reads suppressed. |
community-scoped | Reserved for v1 substrate enforcement; should not be served to parties outside the named community once enforcement lands. |
private | Should not be published at all. |
The substrate does not enforce these today; they are policy hints.
Content-theory types
use
The compound "what was done, on what material, for what end, by which actor" tuple, reused across records whose subject is an action performed, desired, endorsed, or prohibited.
| Subfield | Type | Required | Notes |
|---|---|---|---|
action | string (≤256) | yes | Action identifier, resolved against actionVocabulary. |
material | materialSpec | no | What is being acted on. |
purpose | string (≤256) | no | The end the action serves. |
actor | string (≤256) | no | Who performs or benefits. |
actionVocabulary | vocabRef | no | Vocab the action slug resolves against. |
purposeVocabulary | vocabRef | no | Vocab the purpose slug resolves against. |
materialSpec
| Subfield | Type | Notes |
|---|---|---|
scope | string (≤256) | Community-defined scope (classroom_materials, production_logs, scraped_corpus). |
uri | uri | Optional pointer to a specific dataset. |
lensProperty
A union covering the seven verification kinds. Each verification
record carries one of these as its property field.
| Variant | Used by |
|---|---|
lpRoundtrip | kind: roundtrip-test |
lpGenerator | kind: property-test |
lpTheorem | kind: formal-proof |
lpConformance | kind: conformance-test |
lpChecker | kind: static-check |
lpConvergence | kind: convergence-preserving |
lpCoercionLaw | kind: coercion-law |
lpRoundtrip
| Subfield | Type | Required | Notes |
|---|---|---|---|
domain | string (≤512) | yes | Symbolic description of the input set. |
generator | uri | no | Optional pointer to a generator that enumerates the domain. |
lpGenerator
| Subfield | Type | Required | Notes |
|---|---|---|---|
spec | string (≤2000) | yes | Generator specification (proptest Strategy reference, Hypothesis strategy, QuickCheck Arbitrary). |
runner | string | no | Name of the PBT runtime. |
seed | integer | no | Optional seed for reproducibility. |
lpTheorem
| Subfield | Type | Required | Notes |
|---|---|---|---|
statement | string (≤4000) | yes | The theorem, in the declared system syntax. |
system | string | no | Proof system (coq, lean4, agda, tlaplus, z3). |
freeVariables | array of strings | no | Names of free variables. |
lpConformance
| Subfield | Type | Required | Notes |
|---|---|---|---|
standard | string | yes | Standard identifier (iso-8601, rfc-3339, en-pos-v2.1). |
version | string | yes | Standard version. |
clauses | array of strings | no | Optional subset of the standard's clauses. |
lpChecker
| Subfield | Type | Required | Notes |
|---|---|---|---|
checker | string | yes | Static-checker identifier (panproto-check, clippy, tsc-strict). |
ruleset | string | no | Named ruleset or configuration preset. |
version | string | no | Checker version. |
lpConvergence
| Subfield | Type | Required | Notes |
|---|---|---|---|
property | string (≤1000) | yes | Symbolic name or description of the preserved property. |
boundSteps | integer | no | Optional bound on steps to fixpoint. |
lpCoercionLaw
| Subfield | Type | Required | Notes |
|---|---|---|---|
standard | string (≤256) | yes | Identifier of the coercion-law standard. |
version | string (≤64) | no | Optional version. |
violationThreshold | integer | no | Cap on the violations a runner may report before falsifying. |
evidence
A union of structured witnesses for retrospection findings.
| Variant | Used when finding kind is |
|---|---|
evidenceDivergence | merge-divergence |
evidenceLoss | data-loss |
evidenceMismatch | reconciliation-mismatch |
evidenceDivergence
| Subfield | Type | Required | Notes |
|---|---|---|---|
pathA | array of lensRef | yes | Lenses composed in path A. |
pathB | array of lensRef | yes | Lenses composed in path B. |
witnessInput | cid-link | no | Optional CID where the two paths diverge. |
evidenceLoss
| Subfield | Type | Required | Notes |
|---|---|---|---|
sourceField | string | yes | Dotted path identifying the lost field. |
targetSchema | schemaRef | no | Target schema where the loss was observed. |
witnessInput | cid-link | no | Witness input. |
evidenceMismatch
| Subfield | Type | Required | Notes |
|---|---|---|---|
leftRecord | cid-link | no | Left record. |
rightRecord | cid-link | no | Right record. |
expectedEqualityOn | string | no | Dotted path or projection under which equality was expected. |
caveat
| Subfield | Type | Required | Notes |
|---|---|---|---|
mode | string | yes | Short failure-mode identifier. |
affects | array of strings | no | Dotted paths or field names. |
severity | enum | no | info / warn / error. |
basis
A union of structured grounds for an attitudinal claim.
| Variant | Use when |
|---|---|
basisSelfAsserted | The holder asserts directly. The default when basis is omitted. |
basisCommunityPolicy | Grounded in a community's published policy. Carries community (at-uri) and optional policyUri. |
basisExternalSignal | Grounded in something outside ATProto. Carries url, optional signalType, optional description. |
basisDerivedFromRecord | Grounded in another ATProto record. Carries source (strongRecordRef) and optional inferenceRule. |
basisSelfAsserted has no fields; the variant tag itself is the
content. basisDerivedFromRecord.inferenceRule is the canonical
hook for declaring how this record derives from another
(classifier:purpose-v1, lens:v1-to-v2, aggregation:byte-mean,
...).
Concept references
- Concepts: The dev.idiolect.* lexicon family
- Concepts: Records as content-addressed signed data
- The defs are referenced by every other lexicon in the family.
dev.idiolect.deliberation
A community-scoped question or proposal under collective
consideration. Companion records carry the rest of the process:
deliberationStatement for
participant utterances,
deliberationVote for stances on those
utterances, and
deliberationOutcome for the
observer-aggregated tally.
Deliberations are intentionally process-shaped: they represent the
unsettled moment. They are distinct from
belief (settled doxastic) and
recommendation (settled normative). A
deliberation that closes can name an outcome record so consumers
can read the conclusion without re-folding the votes.
Source:
lexicons/dev/idiolect/deliberation.json· Rust:idiolect_records::Deliberation· TS:@idiolect-dev/schema/deliberation· Fixture:idiolect_records::examples::deliberation
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
owningCommunity | at-uri | yes | The community whose membership is deliberating. |
topic | string (≤200 graphemes) | yes | Human-readable topic or question. |
description | string (≤1000 graphemes) | no | Extended framing or context. |
authRequired | boolean (default true) | no | Whether participation requires authenticated membership. |
classification | open enum | no | question / proposal / grievance / retrospective. |
classificationVocab | vocabRef | no | Vocab the classification slug resolves against. |
status | open enum | no | open / closed / tabled / adopted / rejected. |
statusVocab | vocabRef | no | Vocab the status slug resolves against. |
closedAt | datetime | no | When the deliberation moved out of an open status. |
outcome | at-uri | no | Pointer to a deliberationOutcome record summarising the resolved stance. |
createdAt | datetime | yes | Publication timestamp. |
Field details
owningCommunity
The deliberation is scoped to a single community. Membership and participation rights are resolved through that community's record. A deliberation can be cross-referenced from other communities, but exactly one owns it.
The substrate does not enforce membership. authRequired is a
declared policy: when true, only authenticated members'
statements and votes count toward the outcome; when false, the
deliberation accepts drive-by statements (which observers may
weight differently when folding the tally).
classification
| Slug | What it means |
|---|---|
question | An open question without a proposed resolution. |
proposal | A specific proposal under consideration. |
grievance | A complaint or dispute. |
retrospective | A post-hoc review of a prior decision. |
The classification is open-enum: a community publishing its own
classifications vocabulary (negotiation, process-vote,
amendment, ...) extends the slug set. Resolution goes through
classificationVocab when set, otherwise the canonical idiolect
default.
The classification is optional. A community that does not want to commit to a classification omits the field; the deliberation record is still valid, observers and consumers just have less metadata to fold on.
status lifecycle
| Slug | What it means |
|---|---|
open | Active. Statements and votes accepted. |
closed | No longer accepting input. May or may not have an outcome. |
tabled | Closed but explicitly deferred for later. |
adopted | Closed with a positive resolution. |
rejected | Closed with a negative resolution. |
Open-enum: a community that wants finer-grained statuses
(closed-pending-revision, escalated, ...) extends via
statusVocab. The lifecycle is a declaration; the substrate
records the value the publisher set.
outcome
A pointer to a dev.idiolect.deliberationOutcome record. Set
after closure when an outcome record exists. Consumers reading
a closed deliberation can fetch the outcome without re-folding
the entire vote stream.
Multiple outcome records per deliberation are allowed (different
observers, different cut-offs); the deliberation's outcome
field points at the canonical one. Consumers who want a
different observer's tally query the orchestrator directly.
closedAt versus createdAt
createdAt is when the deliberation was opened. closedAt is
when it moved out of an open status. The difference is the
deliberation's duration. Observers fold this for cadence
metrics: how long a community typically deliberates before
adopting, how often deliberations are tabled rather than
adopted.
Example
{
"$type": "dev.idiolect.deliberation",
"owningCommunity": "at://did:plc:community/dev.idiolect.community/canonical",
"topic": "Should we adopt the v2 lens for post translations?",
"description": "Community discussion on whether to make the v2 lens the default for member-published posts.",
"authRequired": true,
"classification": "proposal",
"status": "open",
"createdAt": "2026-04-19T00:00:00.000Z"
}
Process flow
flowchart LR
DEL[deliberation] -->|opens| ST1[statement]
DEL -->|opens| ST2[statement]
DEL -->|opens| ST3[statement]
ST1 -->|voted on by| V1[vote]
ST2 -->|voted on by| V2[vote]
ST3 -->|voted on by| V3[vote]
V1 --> OBS[observer fold]
V2 --> OBS
V3 --> OBS
OBS -->|publishes| OUT[deliberationOutcome]
DEL -->|closes with| OUT
A deliberation is opened. Participants publish statements referencing the deliberation. Other participants publish votes referencing specific statements. An observer folds the vote stream and publishes a tally. The deliberation closes with an outcome pointer.
Concept references
- Concepts: Deliberation
- Concepts: The dev.idiolect.* lexicon family
- Lexicons: deliberationStatement · deliberationVote · deliberationOutcome
dev.idiolect.deliberationStatement
A participant utterance submitted to a
deliberation. Statements are the units
votes attach to; the deliberation itself is not voted on
directly. Classification is an open-enum slug resolved against a
community vocabulary, so communities that draw the line between
claim and proposal differently can extend or remap without
forking the lexicon.
Source:
lexicons/dev/idiolect/deliberationStatement.json· Rust:idiolect_records::DeliberationStatement· TS:@idiolect-dev/schema/deliberationStatement· Fixture:idiolect_records::examples::deliberation_statement
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
deliberation | strongRecordRef | yes | AT-URI + CID for the deliberation this statement participates in. |
text | string (≤400 graphemes) | yes | Statement text. |
classification | open enum | no | claim / proposal / dissent / clarification / question. |
classificationVocab | vocabRef | no | Vocab the classification slug resolves against. |
anonymous | boolean (default false) | no | Whether the statement was submitted anonymously. |
createdAt | datetime | yes | Publication timestamp. |
Field details
Why strongRecordRef for the deliberation pointer
deliberation carries both the AT-URI and the CID. Pinning by
CID prevents a later deliberation revision from silently
rescoping the statement. A consumer that reads the statement and
follows the pointer gets the exact deliberation revision the
participant was responding to.
This matters when deliberations are edited mid-process (e.g. the publisher clarifies the topic). Statements published before the edit pin the pre-edit revision; statements published after pin the post-edit revision. Folds and consumers can distinguish.
text
The statement itself. The 400-grapheme cap is conventional, not
arbitrary: brevity is what makes statements voteable. Long-form
context belongs on the deliberation record's description or
in a community-published companion document linked from the
description.
classification
| Slug | What it captures |
|---|---|
claim | An assertion of fact or opinion. |
proposal | A specific proposed action. |
dissent | An objection to a prior statement or to the deliberation framing. |
clarification | A request for or provision of clarification. |
question | An open question requiring an answer. |
Classifications are argumentative roles, not topics. A
community that draws different distinctions (amendment,
process-objection, tangent, ...) extends via
classificationVocab. The classification is optional; a
deliberation that wants to stay agnostic on argumentative roles
omits it.
anonymous
When true, the statement was submitted anonymously. The
typical implementation: the statement is authored on a
designated service DID rather than the participant's personal
repo, so the repo signature does not reveal identity. Consumers
that need provenance match on the repo DID (the service DID),
not on this record's content.
The flag is a declaration: the substrate does not enforce anonymity beyond what the publishing rail provides. A community that wants strong anonymity uses an anonymizing service DID with its own access controls.
Example
{
"$type": "dev.idiolect.deliberationStatement",
"deliberation": {
"uri": "at://did:plc:community/dev.idiolect.deliberation/3l5",
"cid": "bafy..."
},
"text": "Adopting the v2 lens would lose dialect markers on legacy posts.",
"classification": "dissent",
"anonymous": false,
"createdAt": "2026-04-19T00:00:00.000Z"
}
Concept references
dev.idiolect.deliberationVote
A stance taken on a deliberationStatement.
Stance is an open-enum slug resolved against a community-published
vote-stance vocabulary. The Acorn-style three-way default
(agree / pass / disagree) is canonical; richer vocabularies
(conditional-agree, abstain-with-reason, ranked preference) are
expressible by referencing a different vocab. Optional weight
and rationale capture additional signal that observers can fold;
consumers that don't need them ignore them.
Source:
lexicons/dev/idiolect/deliberationVote.json· Rust:idiolect_records::DeliberationVote· TS:@idiolect-dev/schema/deliberationVote· Fixture:idiolect_records::examples::deliberation_vote
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
subject | strongRecordRef | yes | AT-URI + CID for the statement being voted on. |
stance | open enum | yes | agree / pass / disagree. |
stanceVocab | vocabRef | no | Vocab the stance slug resolves against. |
weight | integer ∈ [0, 1000] | no | Optional ranking signal. Convention: scaled by 1000 for the 0.0–1.0 range. |
rationale | string (≤500 graphemes) | no | Optional narrative reason. |
createdAt | datetime | yes | Publication timestamp. |
Field details
Why pin the statement by CID
The subject field carries both AT-URI and CID. A statement
edited after the vote was cast does not retroactively change what
was voted on. Observers folding the tally read the CID to confirm
they are aggregating votes against the same statement revision.
If a statement is edited and a participant wants to vote on the
new revision, that is a separate vote record with a different
subject CID. The substrate does not collapse votes across
revisions; observers do, when their fold method specifies it.
stance
The default vocabulary seeds three slugs:
| Slug | Meaning |
|---|---|
agree | Affirms the statement. |
pass | Abstains. |
disagree | Rejects the statement. |
These match Acorn's +1 / 0 / -1 convention. Communities that
want richer stances publish their own stanceVocab. Common
extensions:
conditional-agree— agree under specified conditions.abstain-with-reason— explicit non-vote with a rationale.rank-1,rank-2, ... — ranked preference.
The stanceVocab machinery means consumers do not have to
coordinate on which vocab is in use ahead of time. The vote
record either references an explicit stanceVocab or falls back
to the canonical idiolect default.
weight
Optional ranking signal. The [0, 1000] integer range encodes
the 0.0–1.0 floating-point range with three decimal places of
precision. Convention follows pub.chive.graph.edge#weight.
Consumers that aggregate votes uniformly ignore weight. Ranked
or weighted aggregations consume it. A community that wants
quadratic voting publishes a vote-weights companion vocabulary
and uses weight to encode the scheme; observers running a
quadratic-vote fold read both the stance and the weight.
rationale
Optional narrative. Tally folds do not consume rationale;
consumer surfaces (e.g. a deliberation viewer) display it
alongside the vote. The 500-grapheme cap matches the
deliberation-statement length: brevity is conventional.
Anonymous votes
There is no anonymous flag on votes (unlike statements). If a
community wants anonymous voting, the implementation is the same
as anonymous statements: votes are authored on a designated
service DID rather than the voter's personal repo. The repo
signature is the authoritative provenance signal.
Example
{
"$type": "dev.idiolect.deliberationVote",
"subject": {
"uri": "at://did:plc:community/dev.idiolect.deliberationStatement/3l5",
"cid": "bafy..."
},
"stance": "agree",
"weight": 750,
"rationale": "Strong agree, conditional on the dialect-marker preservation work shipping first.",
"createdAt": "2026-04-19T00:00:00.000Z"
}
Folded into the outcome
A vote does not produce an outcome on its own. An observer reads
the vote stream for a deliberation, folds by (statement, stance)
(plus optional weight aggregation), and publishes a
deliberationOutcome record. Multiple
observers may publish concurrent outcomes; consumers that want
consensus require quorum across trusted observers.
Concept references
- Concepts: Deliberation
- Concepts: Observer protocol
- Lexicons: deliberation · deliberationStatement · deliberationOutcome
dev.idiolect.deliberationOutcome
Observer-aggregated tally for a deliberation.
Not a participant-authored record: produced by an observer fold
over the vote stream and published from the observer's repo.
Consumers reading a closed deliberation can fetch the outcome
directly rather than re-folding every vote. Tallies are
per-statement and per-stance, so consumers can render a
Polis-style opinion map without further computation.
Source:
lexicons/dev/idiolect/deliberationOutcome.json· Rust:idiolect_records::DeliberationOutcome· TS:@idiolect-dev/schema/deliberationOutcome· Fixture:idiolect_records::examples::deliberation_outcome
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
deliberation | strongRecordRef | yes | AT-URI + CID for the deliberation. |
statementTallies | array (≤4096) of statementTally | yes | Per-statement vote counts. |
adopted | array (≤256) of strongRecordRef | no | Statements the community adopted. |
stanceVocab | vocabRef | no | Vocab the per-tally stance slugs resolve against. |
computedAt | datetime | yes | When the observer computed this tally. |
tool | tool | no | Identity and version of the aggregator. |
occurredAt | datetime | yes | Publication timestamp. |
statementTally
| Subfield | Type | Required | Notes |
|---|---|---|---|
statement | strongRecordRef | yes | The statement these counts aggregate. |
counts | array (≤64) of stanceCount | yes | Per-stance vote counts. |
weightedCounts | array (≤64) of stanceCount | no | Per-stance weighted vote counts (when votes carried weight). Scaled by 1000. |
stanceCount
| Subfield | Type | Required | Notes |
|---|---|---|---|
stance | string (≤256) | yes | Stance slug, resolved through the outcome's stanceVocab. |
count | non-negative integer | yes | Vote count. For weightedCounts, scaled by 1000. |
Field details
Why outcomes are observer-published
The deliberation owns the topic; participants own the statements and votes. The aggregate is opinion: it depends on the observer's fold method, the cut-off time, and which encounter kinds it weights. Two observers can produce different but defensible outcomes for the same deliberation.
The substrate's answer: outcomes are records, signed by the observer, comparable across observers. A consumer that distrusts one observer's fold can:
- Fetch all outcomes for the deliberation.
- Pick one based on the observer's identity or the
toolfield. - Require quorum among trusted observers.
- Re-fold the vote stream itself.
Why a single stanceVocab per outcome
The outcome record uses one stance vocabulary across all tallies. An observer that sees votes referencing different vocabularies must either:
- Publish separate outcomes per vocab, each tallying votes that share a vocab.
- First translate via a
mapEnumlens (see Open enums) into a single target vocabulary, then tally.
Mixing vocabularies in a single outcome is invalid: the same slug in two different vocabularies has different semantics, and adding their counts is meaningless.
statementTallies
One entry per statement that received at least one vote. Statements with zero votes are omitted. Each tally carries:
- The statement (strong-ref, so consumers fetching the tally can fetch the exact statement revision being tallied).
- The per-stance counts.
- Optional weighted counts when the underlying votes carried
weight.
The 4096-entry cap matches the maximum statement count per deliberation in practice; communities expecting more should publish multiple outcome records partitioned by statement window.
adopted
A list of strong-refs to statements the community adopted as the deliberation's resolution. An adopted statement is one the community treats as the answer to a question, the resolution of a proposal, or the action item from a grievance.
Adoption is a community decision, not a fold rule. The observer publishing the outcome typically follows the deliberation's publishing community: their criterion for adoption (majority agree, supermajority, consensus) is what the observer encodes in this list. A different observer running a different criterion would publish a different outcome.
adopted is empty when the deliberation closed without
adoption (rejected, tabled, or closed without resolution).
tool and method versioning
The tool field carries the aggregator's identity and version.
Two outcomes for the same deliberation produced by different
tools (or different versions of the same tool) are not directly
comparable: the algorithm differs. Consumers compare outcomes
across tools at their own risk; the substrate records the tool
identity so the comparison is at least informed.
Example
{
"$type": "dev.idiolect.deliberationOutcome",
"deliberation": {
"uri": "at://did:plc:community/dev.idiolect.deliberation/3l5",
"cid": "bafy..."
},
"statementTallies": [
{
"statement": {
"uri": "at://did:plc:community/dev.idiolect.deliberationStatement/stmt1",
"cid": "bafy..."
},
"counts": [
{ "stance": "agree", "count": 42 },
{ "stance": "pass", "count": 7 },
{ "stance": "disagree", "count": 3 }
]
},
{
"statement": {
"uri": "at://did:plc:community/dev.idiolect.deliberationStatement/stmt2",
"cid": "bafy..."
},
"counts": [
{ "stance": "agree", "count": 18 },
{ "stance": "pass", "count": 12 },
{ "stance": "disagree", "count": 22 }
]
}
],
"adopted": [
{
"uri": "at://did:plc:community/dev.idiolect.deliberationStatement/stmt1",
"cid": "bafy..."
}
],
"computedAt": "2026-04-30T00:00:00.000Z",
"tool": {
"name": "deliberation-tally",
"version": "1.0.0"
},
"occurredAt": "2026-04-30T00:01:00.000Z"
}
Concept references
- Concepts: Deliberation
- Concepts: Observer protocol
- Lexicons: deliberation · deliberationVote · observation
dev.idiolect.dialect
A community's bundle of idiolect references and preferred translations. Dialects are declared, not imposed: downstream consumers may adopt, adapt, or ignore them.
Source:
lexicons/dev/idiolect/dialect.json· Rust:idiolect_records::Dialect· TS:@idiolect-dev/schema/dialect· Fixture:idiolect_records::examples::dialect
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
owningCommunity | at-uri | yes | The community that owns this dialect. |
name | string (≤128) | yes | Human-readable dialect name. |
description | string (≤4000 graphemes) | no | Purpose and scope. |
idiolects | array of schemaRef | no | Schemas that constitute the dialect's idiolect set. |
preferredLenses | array of lensRef | no | Translations the community prefers. |
deprecations | array of Deprecation | no | Deprecated entries with replacement pointers. |
version | string | no | Dialect version (semver when applicable). |
previousVersion | at-uri | no | Predecessor revision in a version chain. |
createdAt | datetime | yes | Publication timestamp. |
Deprecation
| Subfield | Type | Required | Notes |
|---|---|---|---|
ref | at-uri | yes | The deprecated idiolect or lens. |
replacement | at-uri | no | Optional successor. |
deprecatedAt | datetime | yes | When the deprecation took effect. |
reason | string (≤1000 graphemes) | yes | Why it was deprecated. |
Field details
What a dialect is
A dialect is a bundle. It does not introduce new lexicons; it
collects existing ones into a coherent set the community treats
as canonical. A consumer that adopts the dialect routes
translations through preferredLenses, validates incoming records
against the schemas in idiolects, and treats the deprecation
list as a redirect table.
The dialect record is data, not configuration. Adding an entry is
a record edit; deprecating one is another record edit on the same
dialect with a Deprecation entry. Two dialects from different
communities can list the same NSID with different preferred
lenses; consumers pick a dialect (or a quorum of dialects) and
follow it.
previousVersion and the version chain
A dialect revision points at its predecessor via previousVersion.
A consumer reading the head dialect can walk the chain back
through prior versions, confirm that deprecations were announced
at the right time, and audit the change history without trusting
the orchestrator's catalog.
The chain is not enforced: a community can publish a dialect with
no previousVersion (a fresh start) or skip versions (publishing
v3 with previousVersion = v1). The substrate records what was
done; consumers decide whether to trust it.
deprecations
Each entry records an idiolect or lens that was once part of the
dialect and is now superseded. The ref field points at the
deprecated artifact; replacement optionally points at the
successor. Consumers reading a record at the deprecated ref can
follow replacement to the new one, with the reason field
explaining why.
The lexicon-evolution policy generates deprecation entries automatically when a non-Iso lens revision ships. See Lexicon evolution policy.
Example
{
"$type": "dev.idiolect.dialect",
"owningCommunity": "at://did:plc:community/dev.idiolect.community/canonical",
"name": "tutorial canonical",
"description": "The canonical dialect for the tutorial community.",
"idiolects": [
{ "uri": "at://did:plc:community/dev.panproto.schema.schema/post-v1" }
],
"preferredLenses": [
{ "uri": "at://did:plc:community/dev.panproto.schema.lens/post-v1-to-v2" }
],
"deprecations": [
{
"ref": "at://did:plc:community/dev.panproto.schema.schema/post-v0",
"replacement": "at://did:plc:community/dev.panproto.schema.schema/post-v1",
"deprecatedAt": "2026-04-01T00:00:00.000Z",
"reason": "Replaced by v1 with structured `body` field; lens preserves all v0 records."
}
],
"version": "1.2.0",
"previousVersion": "at://did:plc:community/dev.idiolect.dialect/1.1.0",
"createdAt": "2026-04-19T00:00:00.000Z"
}
Multiple dialects
Two communities can publish disjoint, overlapping, or contradictory dialects. The substrate treats them as opinions; nothing in the protocol prefers one over another. Consumers pick a resolution policy:
first-match— pick the first dialect listed in the consumer's config.quorum— accept a translation when of trusted dialects endorse the same lens path.merge— union the entries; on collision, fall back to a configured tie-breaker.
See Bundle records into a dialect.
Concept references
- Concepts: Idiolect, dialect, language
- Concepts: Lexicon evolution policy
- Guides: Bundle records into a dialect
- Lexicons: community · vocab
dev.idiolect.encounter
A signed record of a single lens invocation. Encounters are the
emergent-channel primitive: they record that a translation
occurred, with enough context for aggregators (observations) and
correctors to reason about it. Narrative commentary lives in
annotations; the structured payload in use covers the
action / material / purpose / actor of the invocation.
Source:
lexicons/dev/idiolect/encounter.json· Rust:idiolect_records::Encounter· TS:@idiolect-dev/schema/encounter· Fixture:idiolect_records::examples::encounter
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
lens | lensRef | yes | The lens that was invoked. |
sourceSchema | schemaRef | yes | Source schema the lens translated from. |
targetSchema | schemaRef | no | Target schema produced by the lens. Often implied by the lens; elided when unambiguous. |
sourceInstance | cid-link | no | Content-addressed reference to the source instance. Omit when visibility restricts publishing source data. |
producedOutput | cid-link | no | Content-addressed reference to the produced output. |
use | use | yes | Structured action / material / purpose / actor. |
downstreamResult | open enum | no | success / corrected / rejected / unknown. |
downstreamResultVocab | vocabRef | no | Vocab the slug resolves against. |
annotations | string (≤4000 graphemes) | no | Narrative commentary. |
holder | did | no | Party the encounter is attributed to. Omit for first-party records. |
basis | basis | no | Structured grounding when holder differs from the repo owner. |
kind | open enum | yes | Corpus-kind slug (invocation-log, curated, roundtrip-verified, production, adversarial). |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
visibility | visibility | yes | public-detailed / public-minimal / public-aggregate-only / community-scoped / private. |
occurredAt | datetime | yes | When the invocation happened. Distinct from the record's createdAt. |
Field details
use
The structured payload. A use carries:
action(open-enum slug, resolved againstactionVocabulary).material(amaterialSpec: scope plus optional corpus pointer).purpose(open-enum slug, resolved againstpurposeVocabulary).actor(string; who ultimately performs or benefits).
All four together form the "what was done, on what, for what
end, by which actor" tuple. Consumers that match on actions
match on subsumption against the referenced vocabulary, not on
substring equality. Two communities that disagree on whether
train_model subsumes fine_tune produce different routing
decisions from the same encounter, which is the right answer.
kind
The encounter-kind slug declares what the corpus represents:
| Slug | Meaning |
|---|---|
invocation-log | A real production invocation. |
curated | A hand-picked sample, often used for evaluation. |
roundtrip-verified | An invocation where put(get(a)) == a was verified at write time. |
production | Synonym for invocation-log in some pipelines; exists for distinct trust weighting. |
adversarial | An invocation explicitly chosen to stress the lens. |
Observers declare in their method which kinds they weight and
how. An observation aggregating invocation-log plus curated
encounters is meaningfully different from one aggregating only
adversarial ones; the kind plus the observer's method together
is what makes the observation interpretable.
downstreamResult
The invoking party's at-record-time assessment of the outcome:
| Slug | Meaning |
|---|---|
success | The output was accepted unchanged. |
corrected | The output was edited; a dev.idiolect.correction record exists or is expected. |
rejected | The output was unusable. |
unknown | The party publishing did not know yet. |
corrected is the link between the emergent-channel encounter
record and the correction record that documents the edit.
Consumers reading correction records traverse back through the
encounter's downstreamResult to confirm the link.
holder and basis
Most encounters are first-party: the repo owner is the party that
invoked the lens. Some are third-party: a labeler records that
another party invoked a lens. holder names the party the
record is attributed to; basis carries structured grounds for
the attribution (a community policy, an external signal, an
inference from another record). See defs#basis for
the variants.
visibility
The five values are policy hints, not access control. The
substrate does not enforce them today. Records marked
community-scoped should not be served to parties outside the
named community once the substrate supports scope enforcement;
records marked private should not be published at all.
Example
{
"$type": "dev.idiolect.encounter",
"lens": { "uri": "at://did:plc:lens-author/dev.panproto.schema.lens/3l5" },
"sourceSchema": { "uri": "at://did:plc:schema-author/dev.panproto.schema.schema/v1" },
"targetSchema": { "uri": "at://did:plc:schema-author/dev.panproto.schema.schema/v2" },
"use": {
"action": "train_model",
"material": { "scope": "production_logs" },
"purpose": "non_commercial",
"actor": "researchers"
},
"downstreamResult": "success",
"kind": "invocation-log",
"visibility": "public-detailed",
"occurredAt": "2026-04-19T12:30:00.000Z"
}
How encounters are consumed
flowchart LR
PUB[publisher] -->|writes| ENC[encounter]
ENC -->|firehose| OBS[observer]
OBS -->|tally| OBSREC[observation]
ENC -.referenced by.-> COR[correction]
ENC -.referenced by.-> RET[retrospection]
OBSREC -.cited by.-> BEL[belief]
An encounter is the unit; observations and corrections reference
it. A dev.idiolect.belief may cite either the encounter
directly (for narrow claims) or an observation that aggregated
it (for broad claims). A dev.idiolect.retrospection references
an encounter to record a delayed finding about it.
Cross-references
- Concepts: The dev.idiolect.* lexicon family
- Concepts: Records as content-addressed signed data
- Guides: Index a firehose
- Lexicons: observation · correction · retrospection
dev.idiolect.observation
A signed aggregate over a set of encounter-family records. Observations decouple ranking from the orchestrator: many observers publish competing aggregates over the same traces, and consumers choose whom to trust.
Source:
lexicons/dev/idiolect/observation.json· Rust:idiolect_records::Observation· TS:@idiolect-dev/schema/observation· Fixture:idiolect_records::examples::observation
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
observer | did | yes | DID of the observer publishing this aggregate. |
method | object | yes | { name, description?, codeRef?, parameters? }. The aggregator identity and configuration. |
scope | object | yes | The set of records the observation aggregates over. |
output | unknown | yes | Method-defined payload (counts, scores, diagnostic summaries). |
version | string | yes | Method version. Different versions may produce non-comparable outputs. |
basis | basis | no | Grounding when the observer is not the repo owner. |
visibility | visibility | yes | Visibility scope. |
occurredAt | datetime | yes | When the observation was published. |
method
| Subfield | Type | Required | Notes |
|---|---|---|---|
name | string (≤128) | yes | Short method identifier. |
description | string (≤4000 graphemes) | no | Narrative method description. |
codeRef | at-uri | no | Reference to the method's source or specification. |
parameters | unknown | no | Free-form JSON, observer-defined. |
scope
| Subfield | Type | Required | Notes |
|---|---|---|---|
lenses | array of lensRef | no | Lenses included; empty or omitted means "all". |
communities | array of at-uri | no | Communities whose records are in scope. |
encounterKinds | array of open-enum slugs | no | Encounter kinds weighted in the aggregation. |
encounterKindsVocab | vocabRef | no | Vocab the kind slugs resolve against. |
window | { from?, until? } | no | Time window. |
Field details
output
Deliberately untyped (unknown). The shape is determined by
method. Common shapes:
- A correction-rate ranking:
[{ lens, rate, sampleCount }]. - A quality score:
{ score, ci_low, ci_high }. - A structured diagnostic summary:
{ failureModes: [...], hotspots: [...] }.
A per-statement deliberation tally lives in a separate record
kind, deliberationOutcome, rather
than as an observation output.
A consumer reading an observation must know the method to
interpret the output. The method.name plus version plus
optional codeRef together is what makes the output
interpretable.
scope.encounterKinds
The observer must disclose which encounter kinds it includes or
the observation is uninterpretable. An observation that includes
adversarial encounters at the same weight as invocation-log
encounters is meaningfully different from one that excludes
adversarial samples; consumers reading the observation rely on
this disclosure to decide whether the result fits their use case.
version versus occurredAt
version is the method's version. Two observations with the same
method.name but different versions are not comparable: the
algorithm changed. occurredAt is when the observation was
published. Two observations with the same version but different
occurredAts are comparable as time-series data.
basis
Most observations are first-party (the repo owner is the
observer). When the observer is a third party (a relay, a cache,
another aggregator that took someone else's output and republished
it), basis records the grounds: typically derivedFromRecord
pointing at the original observation, with inferenceRule set to
the relay or transformation kind.
Example
{
"$type": "dev.idiolect.observation",
"observer": "did:plc:observer.dev",
"method": {
"name": "encounter-throughput",
"version": "1.0.0",
"parameters": { "windowSeconds": 3600 }
},
"scope": {
"lenses": [
{ "uri": "at://did:plc:lens-author/dev.panproto.schema.lens/3l5" }
],
"encounterKinds": ["invocation-log", "production"],
"window": {
"from": "2026-04-19T00:00:00.000Z",
"until": "2026-04-19T01:00:00.000Z"
}
},
"output": {
"total": 1042,
"byKind": {
"invocation-log": 940,
"production": 102
},
"byDownstreamResult": {
"success": 991,
"corrected": 37,
"rejected": 12,
"unknown": 2
}
},
"version": "1.0.0",
"visibility": "public-detailed",
"occurredAt": "2026-04-19T01:00:05.000Z"
}
Why observations and not metrics
A central metrics endpoint cannot:
- Be verified after the fact (the counter is whatever the endpoint says it is).
- Be re-folded by an independent party.
- Disagree with itself across observers.
A signed observation can. Two observers running the same fold on overlapping data will produce records with comparable counts; consumers can require quorum among trusted observers before treating an observation as authoritative.
Concept references
- Concepts: Observer protocol
- Concepts: The dev.idiolect.* lexicon family
- Guides: Run the observer daemon
- Lexicons: encounter · deliberationOutcome
dev.idiolect.recommendation
A community-published opinionated path with structured applicability
conditions and optional verification requirements. The conditions,
preconditions, and caveats arrays are structured: a consumer
can evaluate them mechanically against an invocation context. The
requiredVerifications array is a list of specific lens properties
the recommendation assumes are in place, so consumers check which
roundtrip domain or theorem or standard has been established, not
just which verification kind was run. Narrative prose lives in
annotations and caveatsText.
Source:
lexicons/dev/idiolect/recommendation.json· Rust:idiolect_records::Recommendation· TS:@idiolect-dev/schema/recommendation· Fixture:idiolect_records::examples::recommendation
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
issuingCommunity | at-uri | yes | Community publishing the recommendation. |
conditions | array (≤128) of Condition | yes | Structured applicability predicate. Empty array means "always applies". |
preconditions | array (≤128) of Condition | no | Additional structured assumptions the consumer must verify. |
lensPath | array (≥1) of lensRef | yes | Ordered sequence of lenses to compose. |
annotations | string (≤8000 graphemes) | no | Narrative explanation. |
requiredVerifications | array of lensProperty | no | Specific properties the recommendation assumes. |
caveats | array (≤32) of Caveat | no | Structured failure-mode list. |
caveatsText | string | no | Narrative companion to caveats. |
basis | basis | no | Structured grounding for the attitudinal claim. |
supersedes | at-uri | no | Prior recommendation this one replaces. |
occurredAt | datetime | yes | Publication timestamp. |
The condition tree
conditions and preconditions are postfix-operator trees over
the combinator set defined inline in this lexicon:
| Variant | Arity | Purpose |
|---|---|---|
conditionSourceIs | atomic | Match invocations whose source schema equals the named at-uri. |
conditionTargetIs | atomic | Match invocations whose target schema equals the named at-uri. |
conditionActionSubsumedBy | atomic | Match invocations whose use.action is subsumed by the named slug in the named action vocabulary. |
conditionPurposeSubsumedBy | atomic | Match invocations whose use.purpose is subsumed by the named slug. |
conditionDataHas | atomic | Match invocations whose data carries the named community-defined property identifier (e.g. length>1024, contains-pii, multilingual). |
conditionAnd | combinator | Pop the top two predicates and conjoin them. |
conditionOr | combinator | Pop the top two predicates and disjoin them. |
conditionNot | combinator | Pop the top predicate and negate it. |
Postfix evaluation: walk the array left to right, push atomic predicates onto a stack, pop operands when a combinator is encountered, push the result. The final stack must contain exactly one truth value, which is the predicate's result.
This shape is closer to a Reverse Polish expression than to a
nested object tree. The wire representation is flat (an array of
discriminated objects), which matches ATProto's union shape and
keeps the validator simple.
Field details
lensPath
The recommendation endorses a path of lenses, not just a single
lens. A lensPath of length 1 is the single-lens case; longer
paths are a community's recommendation for a chained translation
(e.g. v1 → middle-form → v3 instead of a direct v1 → v3 lens
when the direct lens has worse properties).
A consumer that adopts the recommendation calls apply_lens on
each step in order, threading the complement of each step into
the next. See Concepts: Lens semantics.
requiredVerifications
A list of specific lens properties (lensProperty from
defs). Each entry specifies what the recommendation
assumes is in place: a particular roundtrip domain, a specific
formal theorem, a conformance to a named standard.
A consumer verifies the recommendation by:
- Querying the verifier registry for verification records on each lens in the path.
- Confirming that each
requiredVerificationis covered by an accepted record (signed by a trusted verifier, withresult: "holds"). - Adopting the recommendation only when all required verifications check out.
This is what makes a recommendation more than an opinion: the required-verifications list pins exactly what the community is relying on, and consumers can audit it.
caveats
A structured failure-mode list. Each caveat has:
| Subfield | Type | Notes |
|---|---|---|
mode | string | Short failure-mode identifier (e.g. loses-dialect-markers). |
affects | array of strings | Dotted paths or field names the caveat applies to. |
severity | enum | info / warn / error. |
Consumers match on mode and affects to decide whether the
caveat applies to their use case. severity is advisory; an
error caveat is the community's notice that the recommendation
should not be adopted in cases the caveat covers, and a consumer
ignoring it is on its own.
Example
{
"$type": "dev.idiolect.recommendation",
"issuingCommunity": "at://did:plc:community/dev.idiolect.community/canonical",
"conditions": [
{ "$type": "dev.idiolect.recommendation#conditionSourceIs",
"schema": { "uri": "at://did:plc:schema-author/dev.panproto.schema.schema/v1" } },
{ "$type": "dev.idiolect.recommendation#conditionPurposeSubsumedBy",
"purpose": "non_commercial",
"vocabulary": { "uri": "at://did:plc:example/dev.idiolect.vocab/purposes" } },
{ "$type": "dev.idiolect.recommendation#conditionAnd" }
],
"lensPath": [
{ "uri": "at://did:plc:lens-author/dev.panproto.schema.lens/3l5" }
],
"requiredVerifications": [
{ "$type": "dev.idiolect.defs#lpRoundtrip",
"domain": "all valid v1 records with bodies ≤ 1024 bytes" }
],
"caveats": [
{ "mode": "loses-dialect-markers",
"affects": ["body.dialect"],
"severity": "warn" }
],
"occurredAt": "2026-04-19T00:00:00.000Z"
}
How recommendations route translations
flowchart LR
R[recommendation] -->|conditions match| ROUTE[invocation context]
R -->|lensPath| L[lens chain]
R -->|requiredVerifications| V[verification records]
V -->|holds + signed by trusted verifier| ACCEPT[accept]
V -->|falsified or missing| REJECT[reject]
L -.applied via.-> APPLY[apply_lens chain]
A consumer queries the orchestrator's recommendation endpoint, filters by community, evaluates each recommendation's conditions against its invocation context, audits the required verifications, and applies the lens path of the surviving recommendation.
Concept references
- Concepts: The dev.idiolect.* lexicon family
- Concepts: Lens semantics and laws
- Tutorial: Publish a recommendation
- Lexicons: verification · defs
dev.idiolect.retrospection
A signed annotation of a prior encounter with a delayed finding. Retrospections address silent-error latency: merges, migrations, and bitemporal reconciliations often surface failures only after long delay.
Source:
lexicons/dev/idiolect/retrospection.json· Rust:idiolect_records::Retrospection· TS:@idiolect-dev/schema/retrospection· Fixture:idiolect_records::examples::retrospection
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
encounter | encounterRef | yes | The encounter being retrospected. |
finding | object | yes | { kind, kindVocab?, detail, evidence? }. |
latency | integer (seconds) | no | detectedAt - encounter.occurredAt. Precomputed for aggregation. |
detectingParty | did | yes | DID of the party that detected the issue. |
confidence | number ∈ [0, 1] | no | Optional confidence score. |
disputedAttribution | boolean | no | The detecting party's hint that the causal claim may be contested. |
basis | basis | no | Structured grounding. |
detectedAt | datetime | yes | When the issue was detected. |
occurredAt | datetime | yes | When this retrospection record was published. |
finding
| Subfield | Type | Required | Notes |
|---|---|---|---|
kind | open enum | yes | merge-divergence / data-loss / reconciliation-mismatch / other. |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
detail | string (≤8000 graphemes) | yes | Narrative detail. |
evidence | union of evidenceDivergence / evidenceLoss / evidenceMismatch | no | Structured witness for the finding. |
Why a separate record kind
Encounters are at-record-time. Corrections are short-loop. A retrospection covers the case where the finding surfaces after the original encounter has been folded into observations and moved out of the working set:
- A merge that looked correct at write time turns out to have silently dropped a field three months later.
- A migration that round-tripped on the test corpus loses information on a long-tail input that nobody sampled.
- A bitemporal reconciliation surfaces a divergence between two paths that should have converged.
The encounter / correction / observation triple cannot capture these without distorting their semantics. A retrospection record is the right shape: it points at the original encounter, names a delayed finding, and carries the evidence.
The four finding kinds
| Slug | Evidence shape | What it captures |
|---|---|---|
merge-divergence | evidenceDivergence (paths A and B + witness input) | Two translation paths that should have converged but produced different outputs. |
data-loss | evidenceLoss (source field path + target schema + witness input) | A source-schema field unrepresented in the target after translation. |
reconciliation-mismatch | evidenceMismatch (left and right records + expected equality projection) | Two records that should reconcile under the lens but do not. |
other | (optional) | Catch-all; evidence may be omitted, detail carries the whole finding. |
The structured evidence variants let downstream consumers match
on the failure mode without parsing the narrative detail. An
aggregator that wants to surface "lenses with high data-loss
counts" filters by finding.kind = data-loss and folds.
Field details
latency
Precomputed for aggregation convenience. The value is
detectedAt - encounter.occurredAt in seconds. Folds that bucket
findings by latency (e.g. "what's the median time-to-detect for
merge-divergence findings?") read this field directly. Authors
may omit it for kind: other findings where latency is not
meaningful.
confidence
Optional, in [0, 1]. A finding the detecting party is sure of
omits it. A finding hedged on uncertain evidence sets a value
below 1. Aggregators may weight findings by confidence; consumers
treating findings as ground truth filter for high confidence.
disputedAttribution
A hint the detecting party expects the causal attribution to be
contested. The substrate does not enforce contestation; a
contesting party publishes its own retrospection with
disagreement, or a dev.idiolect.belief over the finding. The
flag exists so consumers can flag the finding as
"interpretation pending" rather than treating it as settled.
detectingParty versus the repo signer
detectingParty is the party who actually detected the issue.
Most retrospections are first-party: the repo owner is the
detecting party. Some are third-party: a researcher republishing
a finding from a trusted source. holder is not a field here
(unlike encounter / belief / correction); detectingParty plus
the repo signer carry the relevant attribution.
Example
{
"$type": "dev.idiolect.retrospection",
"encounter": {
"uri": "at://did:plc:user/dev.idiolect.encounter/3l5"
},
"finding": {
"kind": "data-loss",
"detail": "Lens dropped the `provenance` array on records with > 100 entries; not detected at write time because all sampled inputs had ≤ 50 entries.",
"evidence": {
"$type": "dev.idiolect.defs#evidenceLoss",
"sourceField": "provenance",
"targetSchema": {
"uri": "at://did:plc:schema-author/dev.panproto.schema.schema/v2"
}
}
},
"latency": 7776000,
"detectingParty": "did:plc:detector",
"confidence": 0.95,
"detectedAt": "2026-07-19T00:00:00.000Z",
"occurredAt": "2026-07-19T00:01:00.000Z"
}
Concept references
dev.idiolect.verification
A signed assertion of a formal property of a lens. Verifications
are the formal-channel primitive: they coexist with the emergent
channel (encounters, corrections, observations) and neither gates
the other. property is a structured lensProperty (see
defs) so consumers dispatch on the specific claim:
a Theorem for proof checkers, a GeneratorSpec for PBT runners,
a ConformanceStandard for conformance runners, and so on.
Source:
lexicons/dev/idiolect/verification.json· Rust:idiolect_records::Verification· TS:@idiolect-dev/schema/verification· Fixture:idiolect_records::examples::verification
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
lens | lensRef | yes | The lens whose property is being asserted. |
kind | open enum | yes | roundtrip-test / property-test / formal-proof / conformance-test / static-check / convergence-preserving / coercion-law. |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
verifier | did | yes | DID of the party asserting the verification. |
tool | tool | yes | Tool identity and version. |
property | union of seven lensProperty shapes | yes | Structured statement of what is being asserted. |
result | open enum | yes | holds / falsified / inconclusive. |
resultVocab | vocabRef | no | Vocab the result slug resolves against. |
counterexample | cid-link | no | For result: falsified: minimal counterexample. |
dependencies | array of at-uri | no | Other verifications this one depends on (e.g. a proof assuming a lemma). |
proofArtifact | cid-link | no | For kind: formal-proof: checkable proof artifact (Coq / Lean / Agda term). |
basis | basis | no | Structured grounding when relevant. |
occurredAt | datetime | yes | When the verification was recorded. |
The seven kinds
Each kind has its own property shape, defined in
defs#lensProperty. The kind plus the property
together pin exactly what was verified.
| Kind | Property shape | What the runner does |
|---|---|---|
roundtrip-test | lpRoundtrip (domain string + optional generator URI) | Run put(get(a)) == a on samples drawn from the domain. |
property-test | lpGenerator (spec + runner identifier + seed) | Run an arbitrary boolean predicate over generator samples. |
formal-proof | lpTheorem (statement in proof-system syntax + system + free variables) | Check the proof artifact in the named system. |
conformance-test | lpConformance (standard identifier + version + clause subset) | Run the standard's conformance suite. |
static-check | lpChecker (checker identifier + ruleset + version) | Run the checker against the lens chain. |
convergence-preserving | lpConvergence (property + optional step bound) | Verify the property is preserved under repeated application (fixpoint, reconciliation). |
coercion-law | lpCoercionLaw (standard + version + violation threshold) | Check panproto's coercion-law gate over samples. |
A consumer reading a verification record dispatches on kind,
matches against the embedded property, and decides whether the
specific verification meets its needs. A roundtrip-test verification
that covers domain: "all valid v1 records with bodies ≤ 1024 bytes"
is meaningfully different from one that covers domain: "the training corpus"; both are valid, neither subsumes the other.
Field details
result
| Slug | Meaning |
|---|---|
holds | The runner did not falsify the property within its budget. |
falsified | The runner found a counterexample. |
inconclusive | The runner ran out of time, the corpus was exhausted, or the proof checker bailed. |
Falsified verifications are first-class records and are how the community learns a lens is wrong. A consumer that ignores a falsified verification is making a routing decision; the substrate records the falsification and lets consumers decide.
tool
The tool field records the tool's name, version, and optional
commit. Consumers reading a verification can decide whether to
trust the tool: panproto-check@0.39.0 plus a known-good commit
is a different signal from a tool the consumer has never heard of.
verifier
The party signing the verification. The PDS commit ties the verifier's signature to the record. Consumers maintain their own trust list of verifiers (per kind) and ignore verifications signed by unknown parties.
dependencies
A formal proof may depend on lemmas. A property test may depend on
a generator that itself was verified. The dependencies array
lists at-uris of those upstream verifications. A consumer auditing
the verification follows the chain, confirms each dependency is
itself trustworthy, and adopts the result only when the entire
chain checks out.
proofArtifact
For kind: formal-proof: a content-addressed reference to the
checkable proof. An orchestrator that has the proof checker
configured can mechanically verify; one that does not takes the
verifier's signed assertion on trust. The proof artifact is the
escape hatch from "trust the verifier" to "check the proof
yourself".
counterexample
For result: falsified: a content-addressed reference to a
minimal counterexample (often the smallest sample the runner
found that violated the property). Consumers can fetch the
counterexample, replay the lens against it, and confirm the
falsification independently.
Example
{
"$type": "dev.idiolect.verification",
"lens": { "uri": "at://did:plc:lens-author/dev.panproto.schema.lens/3l5" },
"kind": "roundtrip-test",
"verifier": "did:plc:verifier",
"tool": {
"name": "panproto-check",
"version": "0.39.0",
"commit": "02158abb"
},
"property": {
"$type": "dev.idiolect.defs#lpRoundtrip",
"domain": "all valid v1 records with bodies ≤ 1024 bytes",
"generator": "https://corpus.example/v1-1k-sample.zip"
},
"result": "holds",
"occurredAt": "2026-04-19T00:00:00.000Z"
}
Verifications and recommendations
A dev.idiolect.recommendation lists requiredVerifications. A
consumer adopting the recommendation queries the verifier registry
for verification records on each lens, accepts the records signed
by trusted verifiers with result: "holds", and confirms each
required verification is covered. A recommendation with required
verifications that nobody has published is a community asking for
work to be done; a dev.idiolect.bounty is the canonical way to
ask for it.
Concept references
- Concepts: Lens semantics and laws
- Concepts: Lexicon evolution policy
- Tutorial: Run a verification
- Guides: Author a verification runner
- Lexicons: defs (
#lensProperty,#tool) · recommendation · bounty
dev.idiolect.vocab
A community-published vocabulary. Two compatible shapes are supported:
- The legacy single-relation tree (
actions+top+world), where every entry declares its direct subsumers and the world pins the inference discipline. - The typed multi-relation knowledge graph (
nodes+edges), where every node is first-class and edges carry a relation slug.
Authors choose either. Consumers normalize the legacy tree to the
graph form at access time by lifting each actionEntry to a
node and each parents entry to a subsumed_by edge. New
vocabularies should prefer the graph shape; the tree stays valid
for backward compatibility and remains the right shape for pure
subsumption hierarchies.
The graph shape is modelled after pub.chive.graph.{node,edge},
so cross-vocabulary translation, SKOS-style
broader_than/narrower_than mappings, and external-id
alignment (Wikidata, ROR, ORCID, ...) are first-class.
Source:
lexicons/dev/idiolect/vocab.json· Rust:idiolect_records::Vocab· TS:@idiolect-dev/schema/vocab· Fixture:idiolect_records::examples::vocab
Top-level shape
| Field | Type | Required | Notes |
|---|---|---|---|
name | string (≤128) | yes | Human-readable name. |
description | string (≤4000 graphemes) | no | Narrative description. |
world | enum | yes | closed-with-default / open / hierarchy-closed. Default subsumption discipline. |
defaultRelation | at-uri | no | Pointer to the relation-kind node consumers should treat as canonical when no relation is specified. |
top | string (≤256) | no | Identifier of the vocabulary's top action under subsumed_by. Required when actions is populated and world=closed-with-default. |
actions | array (≤4096) of actionEntry | no | Legacy tree shape. |
nodes | array (≤4096) of vocabNode | no | Graph shape. |
edges | array (≤16384) of vocabEdge | no | Graph shape. |
supersedes | at-uri | no | Prior vocabulary this one replaces. |
occurredAt | datetime | yes | Publication timestamp. |
The world discipline
A closed-enum field naming how undeclared identifiers are treated:
| Slug | Behavior |
|---|---|
closed-with-default | Every undeclared id is subsumed by top and nothing else. The natural default for hierarchical taxonomies. |
open | Undeclared ids are incomparable to every declared id. Right when the vocab is one community's view of an open-ended space. |
hierarchy-closed | Only the declared edges exist. Strictest; appropriate when the vocabulary is a closed enumeration. |
The slug is closed enum: it is meta-policy on the inference engine, not a domain term, so extending it would change runtime semantics. Per-relation overrides live on the relation node's metadata.
The actionEntry (legacy tree)
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (≤256) | yes | Stable action identifier. |
parents | array of strings | yes | Direct subsumers. Empty for top. |
class | string (≤256) | no | Identifier of the attitudinal composition this action instances. |
description | string (≤500 graphemes) | no | Optional human-readable description. |
The vocabNode (graph form)
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (≤256) | yes | Stable slug. Used as edge endpoint. |
kind | open enum | no | concept / relation / instance / type / collection. |
kindVocab | vocabRef | no | Vocab the kind slug resolves against. |
subkindUri | at-uri | no | Pointer to another node typing this node's subkind. |
label | string (≤500) | no | Primary human-readable label (SKOS prefLabel). |
alternateLabels | array (≤50) | no | SKOS altLabel. |
hiddenLabels | array (≤50) | no | SKOS hiddenLabel: searchable, not displayed. |
description | string (≤2000 graphemes) | no | SKOS definition. |
scopeNote | string (≤2000 graphemes) | no | SKOS scopeNote: usage guidance. |
example | string (≤2000 graphemes) | no | SKOS example. |
historyNote | string (≤2000 graphemes) | no | SKOS historyNote. |
editorialNote | string (≤2000 graphemes) | no | SKOS editorialNote. |
changeNote | string (≤2000 graphemes) | no | SKOS changeNote. |
notation | string (≤500) | no | SKOS notation: non-text identifier. |
externalIds | array (≤20) of externalId | no | Mappings to external knowledge bases. |
status | open enum | no | proposed / active / deprecated. |
relationMetadata | relationMetadata | no | OWL Lite property characteristics. Required for kind: relation. |
vocabNode.kind
| Slug | Meaning |
|---|---|
concept | A SKOS concept; the typical case. |
relation | This node represents a relation type. Edges with this slug reference this node. |
instance | An individual; a specific entity. |
type | A metaclass; a type-of-types. |
collection | A SKOS Collection. Members linked via member_of edges. |
The vocabEdge
| Field | Type | Required | Notes |
|---|---|---|---|
source | string | yes | Source node id. |
target | string | yes | Target node id. |
relationSlug | string | yes | Relation slug. References a relation-kind node. |
metadata | unknown | no | Edge metadata, free-form. |
OWL Lite property characteristics
A relation-kind node carries metadata declaring algebraic
properties:
| Property | Meaning |
|---|---|
symmetric | |
asymmetric | |
transitive | |
reflexive | for all in scope |
irreflexive | for all in scope |
functional | |
inverseFunctional | |
inverseOf | Pointer to the inverse relation node. |
world | Per-relation override of the vocabulary-level world. |
Contradictions (symmetric+asymmetric, reflexive+irreflexive)
are caught at validation time. Functional / inverse-functional
violations are caught at edge-walk time.
VocabGraph::validate walks
the asserted edges and emits one violation per inconsistency.
SKOS Core annotations
The full annotation set on every concept node:
| Field | SKOS counterpart |
|---|---|
label | prefLabel |
alternateLabels | altLabel |
hiddenLabels | hiddenLabel |
description | definition |
scopeNote | scopeNote |
example | example |
historyNote | historyNote |
editorialNote | editorialNote |
changeNote | changeNote |
notation | notation |
externalIds[] | exactMatch / closeMatch / broadMatch / narrowMatch / relatedMatch |
External-id mappings
Each externalId carries:
| Subfield | Type | Notes |
|---|---|---|
system | string | Knowledge-base identifier (wikidata, ror, orcid, lcsh, ...). |
id | string | Identifier in that system. |
match | enum | exact / close / broader / narrower / related. |
External ids enable cross-system translation. A consumer holding
two vocabs with wikidata:Q4116214 external ids can match the
nodes across the vocabs even when the slugs differ.
Backward compatibility
The lifting from tree to graph form is mechanical:
| Tree element | Graph element |
|---|---|
actionEntry | vocabNode { id, kind: "concept" } |
Each parent in actionEntry.parents | vocabEdge { source = entry.id, target = parent, relationSlug: "subsumed_by" } |
actionEntry.class (when present) | vocabEdge { source = entry.id, target = class, relationSlug: "instance_of" } |
top | The unique node with no outbound subsumed_by edge. |
A vocab record with both actions and nodes/edges populated
is interpreted as the union after lifting. This is the
transition shape; new vocabularies should prefer pure graph
form.
Example (graph form)
{
"$type": "dev.idiolect.vocab",
"name": "vote-stances",
"description": "Default deliberation vote stances.",
"world": "open",
"nodes": [
{ "id": "agree", "kind": "concept", "label": "Agree" },
{ "id": "disagree", "kind": "concept", "label": "Disagree" },
{ "id": "pass", "kind": "concept", "label": "Pass" },
{ "id": "polar_opposite_of",
"kind": "relation",
"label": "Polar opposite of",
"relationMetadata": { "symmetric": true, "irreflexive": true }
}
],
"edges": [
{ "source": "agree",
"target": "disagree",
"relationSlug": "polar_opposite_of" }
],
"occurredAt": "2026-04-19T00:00:00.000Z"
}
Concept references
- Concepts: Open enums and vocabularies
- Concepts: The vocabulary knowledge graph
- Guides: Author a community vocabulary
- Crate reference: idiolect-records (
VocabGraph,VocabRegistry)
CLI
idiolect is the command-line tool. The full surface is below.
Top-level subcommands
idiolect resolve <did>
idiolect fetch <at-uri>
idiolect orchestrator <subcommand>
idiolect encounter record [...]
idiolect oauth login | list | logout [...]
idiolect publish <kind> --record <path> [...]
idiolect verify <kind> [...]
idiolect version # also accepts --version, -V
idiolect help [<sub>] # also accepts --help, -h
The hand-written subcommands (resolve, fetch, oauth,
publish, verify, encounter) live in their own modules
under crates/idiolect-cli/src/. The orchestrator subcommand
is generated from orchestrator-spec/queries.json and routes
its calls to the orchestrator's HTTP API.
resolve
idiolect resolve <did>
Resolve a DID via idiolect-identity::ReqwestIdentityResolver.
Prints { did, method, handle, pds_url, also_known_as }.
fetch
idiolect fetch <at-uri>
Fetch a record body via com.atproto.repo.getRecord (under the
hood: idiolect-lens::fetcher_for_did). Prints the record value
as JSON.
orchestrator <subcommand>
The orchestrator dispatcher accepts a flat path-and-flags shape
generated from orchestrator-spec/queries.json. Each query
maps onto a subcommand and a flag set; the CLI translates the
invocation into an HTTP path and calls the orchestrator at
--url (default http://localhost:8787).
The current subcommands (run idiolect help orchestrator for
the live list):
| Command | Calls |
|---|---|
idiolect orchestrator adapters --framework <NAME> | GET /v1/adapters?framework=... |
idiolect orchestrator bounties | GET /v1/bounties/open |
idiolect orchestrator bounties --requester_did <DID> | GET /v1/bounties/by-requester?requester_did=... |
idiolect orchestrator recommendations | GET /v1/recommendations |
idiolect orchestrator verifications --lens_uri <AT-URI> | GET /v1/verifications?lens_uri=... |
Adding a query to the spec extends both the HTTP and the CLI
surface; see Run codegen. The CLI's
top-level --url flag overrides the default orchestrator base.
oauth
Authenticated PDS sessions for idiolect publish and downstream
record-writing flows.
idiolect oauth login --handle HANDLE --app-password PASSWORD [--pds-url URL]
idiolect oauth list
idiolect oauth logout --did DID
login exchanges (handle, app-password) for an access JWT via
com.atproto.server.createSession and persists {did, handle, pds_url, access_jwt, refresh_jwt} as one JSON file per DID
under $IDIOLECT_SESSION_DIR (default
~/.config/idiolect/sessions/). --app-password may be passed
as a flag or via ATPROTO_APP_PASSWORD / ATPROTO_PASSWORD
env vars to avoid leaking into shell history.
list enumerates every stored session as a JSON array of
{did, handle, pds_url} triples.
logout deletes the session file for --did; a missing file is
not an error.
publish <kind>
idiolect publish <kind> --record <path> [--rkey RKEY] [--did DID]
Loads a JSON file, validates it against the typed Record impl
for <kind> (which can be either the unqualified kind like
recommendation or the fully-qualified NSID like
dev.idiolect.recommendation), splices in a $type
discriminator, and POSTs com.atproto.repo.createRecord using
the stored session's bearer auth.
When --did is omitted the CLI picks the first stored session.
When --rkey is omitted the CLI generates a TID-shaped key.
Prints {uri, cid} of the published record on success.
verify <kind>
idiolect verify roundtrip-test --lens AT_URI [--corpus PATH] [--pds-url URL] [--verifier-did DID]
idiolect verify property-test --lens AT_URI --corpus PATH [--budget N] [--pds-url URL] [--verifier-did DID]
idiolect verify static-check --lens AT_URI [--pds-url URL] [--verifier-did DID]
idiolect verify coercion-law --lens AT_URI --vcs-url URL --standard STD [--version V] [--violation-threshold N] [--verifier-did DID]
Runs the shipped VerificationRunner for the named kind against
the live PDS, prints the typed Verification record as JSON,
and exits non-zero on Falsified / Inconclusive so CI surfaces
failures.
The corpus file (for roundtrip-test and property-test) may
be a JSON array or JSON Lines. property-test's generator
cycles through the corpus by index, so --budget controls how
many cases run.
encounter record
idiolect encounter record \
--lens <AT-URI> --source-schema <AT-URI> [--target-schema <AT-URI>] \
[--vocab <AT-URI>] [--kind <KIND>] [--visibility <V>] [--text-only]
Publishes a dev.idiolect.encounter record. The exact flag set
is in crates/idiolect-cli/src/encounter.rs.
Output format
All commands print pretty-printed JSON to stdout on success. Errors go to stderr:
error: <message>
Pipe stdout to jq for further processing.
Roadmap
The shipped login path uses app passwords in legacy Bearer
mode (com.atproto.server.createSession plus
Authorization: Bearer <token>). The full OAuth + DPoP flow
via atrium-oauth (browser handoff, PKCE, DPoP-bound tokens)
is the next-iteration login UX; the library OAuthSession
shape and OAuthTokenStore trait are already in place to
receive whatever the dance returns.
HTTP query API
Read-only endpoints exposed by idiolect-orchestrator under the
query-http feature. All requests are GET. All responses are
JSON.
The route surface is generated from
orchestrator-spec/queries.json. Each query maps onto two
endpoints: a friendly REST path under /v1/… and an
ATProto-style xrpc path under
/xrpc/dev.idiolect.query.<queryName>. Both call the same
handler. The snapshot below reflects the spec at v0.8.0.
Liveness and metrics
| Path | Returns |
|---|---|
GET /healthz | 200 OK if the process is alive. |
GET /readyz | 200 OK once the catalog has caught up. |
GET /metrics | Prometheus exposition. |
GET /v1/stats | Per-kind record counts. |
Generated query endpoints
| REST path | xrpc path | Returns |
|---|---|---|
GET /v1/bounties/open | /xrpc/dev.idiolect.query.openBounties | Bounties whose status is open, claimed, or unset. |
GET /v1/bounties/want-lens?... | /xrpc/dev.idiolect.query.bountiesForWantLens | Bounties whose wants is a specific lens. |
GET /v1/bounties/by-requester?requester_did=... | /xrpc/dev.idiolect.query.bountiesByRequester | Bounties by requester DID. |
GET /v1/adapters?framework=... | /xrpc/dev.idiolect.query.adaptersForFramework | Adapters declared for a framework. |
GET /v1/adapters/by-invocation-protocol?... | /xrpc/dev.idiolect.query.adaptersByInvocationProtocol | Adapters by invocation-protocol kind. |
GET /v1/adapters/with-verification?... | /xrpc/dev.idiolect.query.adaptersWithVerification | Adapters that carry at least one verification record. |
GET /v1/recommendations | /xrpc/dev.idiolect.query.recommendationsStartingFrom | Recommendations starting from a given source schema. |
GET /v1/verifications?lens_uri=... | /xrpc/dev.idiolect.query.verificationsForLens | Verifications for a specific lens. |
GET /v1/verifications/by-kind?... | /xrpc/dev.idiolect.query.verificationsByKind | Verifications by kind. |
GET /v1/communities?... | /xrpc/dev.idiolect.query.communitiesForMember | Communities for a member DID. |
GET /v1/communities/by-name?... | /xrpc/dev.idiolect.query.communitiesByName | Communities by name. |
GET /v1/dialects/for-community?... | /xrpc/dev.idiolect.query.dialectsForCommunity | Dialects owned by a community. |
GET /v1/beliefs/about?... | /xrpc/dev.idiolect.query.beliefsAboutRecord | Beliefs whose subject is a given record. |
GET /v1/beliefs/by-holder?... | /xrpc/dev.idiolect.query.beliefsByHolder | Beliefs by holder DID. |
GET /v1/vocabularies/by-world?... | /xrpc/dev.idiolect.query.vocabulariesWithWorld | Vocabularies declared with a given world. |
GET /v1/vocabularies/by-name?... | /xrpc/dev.idiolect.query.vocabulariesByName | Vocabularies by name. |
The authoritative parameter list per endpoint is in
orchestrator-spec/queries.json.
The codegen-emitted handlers live in
crates/idiolect-orchestrator/src/generated/http.rs.
Response shape
List endpoints return a JSON envelope whose exact shape is
generated per query. The general pattern: a top-level object
carrying the result list plus pagination metadata. See the
emitted Rust types in
crates/idiolect-orchestrator/src/generated/ for the precise
shape per endpoint.
Error shape
A request that fails parameter validation returns 400 with a JSON body naming the offending field. Internal failures return 500 with a brief message.
Versioning
The v1 and /xrpc/ prefixes are the route contract. New
endpoints are additive. Pre-1.0 the project may rename or
restructure endpoints between minor versions; see
Stability and versioning. At 1.0 the prefixes
become stable and breaking changes ship under v2 (or, for the
xrpc surface, under new method names that deprecate the old).
Stability and versioning
idiolect is pre-1.0. Releases in the 0.x series may include
arbitrary breaking changes between minor versions: Rust APIs,
lexicon shapes, wire formats, daemon HTTP routes, and CLI surfaces
are all in scope.
Pin to an exact version if you depend on this project. Read the changelog before bumping.
What changes between minor versions
Pre-1.0:
- Trait signatures can tighten or widen between minor
versions. The most recent example is the
Resolver/SchemaLoaderSend bound in v0.8.0. - Lexicon shapes can change. Wire-compatible changes go through the lexicon-evolution policy; breaking changes ship with a derived migration lens.
- CLI subcommands can rename or reshape. The output JSON shape is more stable than the flag surface.
- HTTP routes can change under the
v1prefix between minor versions. After 1.0 they will not.
What does not change
- The
dev.idiolect.*namespace stays as is. NSID renames are possible but extraordinarily unusual; one would ship with a deprecation note indev.idiolect.dialect#deprecations. - Records that pass validation continue to pass validation. A record valid against v0.7's lexicon is also valid against v0.8's (the new fields are optional).
- The architectural commitments listed in the README do not change between minor versions: records are signed and content-addressed, lenses obey their stated laws, the lexicons are the single source of truth, the codegen drift gate is on.
What changes at 1.0
- Breaking changes between minor versions stop. Breaking changes ship in major versions only.
- The lexicon-evolution
check-compatgate flips from advisory to a hard fail. - The HTTP API's
v1prefix becomes a stability commitment; new endpoints are additive. - Trait signatures in
idiolect-records,idiolect-lens,idiolect-indexer, andidiolect-orchestratorbecome semver-stable.
The 1.0 release date is not committed. The pre-1.0 series deliberately churns to find the right shape; 1.0 ships when the shape stops moving.
Reading the changelog
The project follows Keep a Changelog plus Semantic Versioning. Every release section has six fixed buckets:
| Bucket | Contents |
|---|---|
| Added | New features. |
| Changed | Behavior changes; trait surface tightenings; lexicon shape changes. |
| Deprecated | Features that still work but are scheduled for removal. |
| Removed | Features that are gone. |
| Fixed | Bug fixes for behavior introduced in earlier versions. |
| Security | Security-relevant fixes. |
The Changelog is in
CHANGELOG.md.
Compatibility matrix
| Component | Source of truth | Lock at |
|---|---|---|
idiolect-records | crates.io | exact version |
@idiolect-dev/schema | npm | exact version |
idiolect CLI | binary release on GitHub | release tag |
idiolect-orchestrator container | ghcr.io/idiolect-dev/orchestrator | image SHA |
idiolect-observer container | ghcr.io/idiolect-dev/observer | image SHA |
The container images are sigstore-signed; verification policy is
in
docs/ci-cd.md.