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:

  1. installed the idiolect CLI and the idiolect-records / idiolect-lens crates,
  2. fetched a real ATProto record and validated it against the shipped lexicon,
  3. resolved a dev.panproto.schema.lens record and applied it to translate a source record into a target record,
  4. run a verification runner against a lens and recorded the result,
  5. published a dev.idiolect.recommendation record 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:* or did: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

ArtifactLocation
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:

  • maxLength and maxGraphemes on strings.
  • format constraints (at-uri, did, nsid, datetime, language, cid-link).
  • knownValues for open enums (the value is preserved verbatim, but the codec records whether it was a known slug or fell through to Other(String) so consumers can decide).
  • required arrays.
  • union discriminator 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:

RecordModuleNSID
Adapteradapterdev.idiolect.adapter
Beliefbeliefdev.idiolect.belief
Bountybountydev.idiolect.bounty
Communitycommunitydev.idiolect.community
Correctioncorrectiondev.idiolect.correction
Deliberationdeliberationdev.idiolect.deliberation
DeliberationStatementdeliberation_statementdev.idiolect.deliberationStatement
DeliberationVotedeliberation_votedev.idiolect.deliberationVote
DeliberationOutcomedeliberation_outcomedev.idiolect.deliberationOutcome
Dialectdialectdev.idiolect.dialect
Encounterencounterdev.idiolect.encounter
Observationobservationdev.idiolect.observation
Recommendationrecommendationdev.idiolect.recommendation
Retrospectionretrospectiondev.idiolect.retrospection
Verificationverificationdev.idiolect.verification
Vocabvocabdev.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 string text child.
  • dev.panproto.schema.schema/tutorial-post-body-v2 — the same shape with the kind relabelled to text.
  • dev.panproto.schema.lens/tutorial-rename-sort-string-to-text — a single-step rename_sort chain. The optic class is Iso; round-trip is byte-equal.

apply_lens is one async call. It does five things in order:

  1. Resolve the lens record from the PDS via PdsResolver.
  2. Load the source and target schemas from the schema loader.
  3. Instantiate the protolens (or protolens chain) against the source schema under the given protocol.
  4. Parse source_record into a panproto w-type instance, project it through get, and serialize the view back to JSON under the target schema.
  5. 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

SymptomCause
LensError::NotFoundThe lens at-uri did not resolve. Check the DID and the rkey.
LensError::LexiconParseThe schema loader returned bytes that were not a valid panproto schema.
LensError::TranslateThe source record did not parse as an instance of the source schema.
Output complement is hugeThe 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-test runs put(get(a)) == a over a corpus of source records.
  • property-test runs an arbitrary boolean predicate over a corpus, suitable for laws beyond round-trip (idempotence, commutativity, naturality, ...).
  • static-check runs panproto's existence and structural checks against the lens chain.
  • coercion-law runs panproto's sample-based coercion-law checker against any CoerceType step.

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:

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.

GuideWhen to reach for it
Index a firehoseYou want to stream commits from a PDS firehose into your own indexer.
Run the orchestrator HTTP APIYou want a read-only query surface over cataloged records.
Run the observer daemonYou want to fold encounter-family records into observation records.
Author a verification runnerYou want to add a new property kind to the verifier.
Publish and resolve a lensYou have a panproto lens and want it on the network.
Migrate records across a revisionA schema you depend on changed; you want to lift records across the change.
Configure OAuth sessionsYou want a session store the publishing path can use.
Run codegenYou edited a lexicon or a spec and need the generated tree refreshed.
Author a community vocabularyYou want to extend an open enum or publish a typed knowledge graph.
Bundle records into a dialectYou 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: yields RawEvents from a PDS firehose. Shipped impls: JetstreamEventStream (Jetstream websocket feed) and TappedFirehoseStream (the at-proto-native firehose via tapped).
  • RecordHandler<F: RecordFamily = IdiolectFamily>: handles one decoded IndexerEvent<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:

  1. 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.
  2. Run cargo run -p idiolect-codegen.
  3. 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 method descriptor (name, version, optional parameters and code reference),
  • a scope describing which records the aggregation covers,
  • the method's output payload (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-tapped feature, transitively pulled in by daemon).
  • A SQLite cursor store.
  • An ObserverHandler<M, P> connecting an ObservationMethod to an ObservationPublisher.
  • 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/.

MethodFolds
correction-ratePer-lens correction counts grouped by reason.
encounter-throughputEncounter traffic by kind and downstream result.
verification-coveragePer-lens verification counts by kind, result, and distinct verifiers.
lens-adoptionPer-lens encounter count and distinct invokers.
action-distributionEncounter counts grouped by use.action, optionally rolled up through a vocab.
purpose-distributionEncounter counts grouped by use.purpose.
basis-distributionRecord counts grouped by basis variant, bucketed by record kind.
attribution-chainsdev.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/:

KindRunner
roundtrip-testRoundtripTestRunner
property-testPropertyTestRunner
static-checkStaticCheckRunner
coercion-lawCoercionLawRunner

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:

  1. Add an entry to verify-spec/runners.json declaring the kind and its description. Run cargo run -p idiolect-codegen; the generated kind taxonomy (crates/idiolect-verify/src/generated.rs) picks up the new kind.
  2. Implement VerificationRunner for a struct in a new module under crates/idiolect-verify/src/. Re-export it from lib.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:

  1. A dev.panproto.schema.lens record on a PDS, carrying the protolens (or protolens chain) blob and pointers to the source and target schemas.
  2. The two schemas themselves, each a dev.panproto.schema.schema record (or a getSchema xrpc 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_hash is a content-addressed identifier for the chain bytes. The VerifyingResolver will refuse to hand the lens to the runtime unless the hash matches the canonical bytes.
  • round_trip_class is the optic class panproto's classifier produces (isomorphism, injection, projection, affine, general). Consumers use this to route review.
  • laws_verified is a soft assertion that the chain passed panproto's coercion-law and existence checks. A true value here is meaningless without a corresponding dev.idiolect.verification record 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.recommendation from a community DID endorsing the lens path under stated conditions.
  • Publish dev.idiolect.verification records covering the properties consumers care about.
  • Register the lens in a dev.idiolect.dialect's preferredLenses so 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:

  1. Hand-author the chain in panproto's protolens DSL.
  2. Run schema lens inspect to classify it.
  3. Run schema theory check-coercion-laws against any CoerceType step.
  4. Run the round-trip runner against a corpus snapshot.
  5. 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

StoreFeatureUse when
InMemoryOAuthTokenStore(always)Tests and fixtures.
FilesystemOAuthTokenStorestore-filesystemA single operator process running on one host. Sessions live under a directory; one file per DID.
SqliteOAuthTokenStorestore-sqliteMulti-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

OutputSourceWhere
Per-record Rust typeslexicons/dev/idiolect/*.json and the vendored lexicons/dev/panproto/*crates/idiolect-records/src/generated/
idiolect-records family modulethe shipped lexiconscrates/idiolect-records/src/generated/family.rs
Per-record fixtures (Rust)lexicons/dev/idiolect/examples/*.jsoncrates/idiolect-records/src/generated/examples.rs
TypeScript validators + typessame lexiconspackages/schema/src/generated/
Orchestrator HTTP routesorchestrator-spec/queries.jsoncrates/idiolect-orchestrator/src/generated/
Orchestrator CLI dispatcherorchestrator-spec/queries.jsoncrates/idiolect-cli/src/generated.rs
Observer method descriptorsobserver-spec/methods.jsoncrates/idiolect-observer/src/generated.rs
Verifier kind taxonomyverify-spec/runners.jsoncrates/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

  1. Drop a JSON document under lexicons/dev/idiolect/.
  2. Run cargo run -p idiolect-codegen.
  3. Optional: add a fixture under lexicons/dev/idiolect/examples/<name>.json so the examples module emits a typed accessor.
  4. 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:

  1. Add a JSON entry to the spec file.
  2. Run cargo run -p idiolect-codegen.
  3. Implement the hand-written half (the panproto-expr predicate for an orchestrator query, the ObservationMethod impl for an observer method, the VerificationRunner impl 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 world is 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.
  • version and previousVersion — 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 *Vocab siblings, not through the dialect).
  • An audit trail of deprecations. A consumer that sees a Deprecation entry can keep reading the deprecated NSID for some grace period and route to replacement afterwards.

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.

ChapterWhat it explains
Idiolect, dialect, languageThe frame the project is named after; what each layer is responsible for.
The dev.idiolect.* lexicon familyThe shipped lexicons, what each one names, and how they compose.
Records as content-addressed signed dataWhy ATProto's record model is the substrate; what the runtime gets for free.
Lens semantics and lawsThe get / put / complement model, GetPut, PutGet, optic classification.
Open enums and vocabulariesWhy every enum field is open; how *Vocab siblings extend slugs.
The vocabulary knowledge graphThe typed multi-relation graph, OWL Lite, SKOS Core, registry queries.
DeliberationThe deliberation lexicons, how they relate to belief / recommendation.
Observer protocolWhy aggregate state lives in records, not in a central endpoint.
Lexicon evolution policyEvery 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:

LinguisticRuntime artifactLexicon
IdiolectA single party's records on a PDS(any record kind)
DialectA bundle published by a communitydev.idiolect.dialect
LanguageThe 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

LexiconWhat it names
dev.idiolect.encounterOne invocation of a lens. Carries the lens, the source schema, the action / material / purpose / actor (use), and the outcome.
dev.idiolect.observationAggregate over encounters folded by an observer. Per-outcome counts plus optional weighted aggregates over a window.
dev.idiolect.correctionA claim that a specific encounter's outcome was wrong, plus the corrected output.
dev.idiolect.beliefA community's standing claim about a lens or schema, citing encounters / observations as evidence.
dev.idiolect.recommendationA community-published opinionated path: lens chain plus structured applicability conditions, preconditions, caveats, and required verifications.
dev.idiolect.bountyA request for someone to do verification work, with structured wantVerification and constraintConformance fields.
dev.idiolect.verificationThe outcome of a verification runner: which lens, which kind, pass/fail, structured report.
dev.idiolect.retrospectionA post-hoc review of a sequence of encounters / observations, with a structured finding.
dev.idiolect.dialectA community-curated bundle: NSIDs, preferred lenses, endorsed vocabularies, deprecations.
dev.idiolect.communityThe community itself: members (with optional roles), record-hosting policy, optional AppView endpoint.
dev.idiolect.vocabA typed multi-relation knowledge graph used to resolve open-enum slugs.
dev.idiolect.adapterA description of an external surface (subprocess, http, wasm, ...) that consumes idiolect records, with isolation policy.
dev.idiolect.deliberationA community-scoped deliberation: topic, classification, status, optional outcome pointer.
dev.idiolect.deliberationStatementOne statement made inside a deliberation.
dev.idiolect.deliberationVoteOne vote on a statement, with an open-enum stance plus optional weight + rationale.
dev.idiolect.deliberationOutcomeAn 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:

  1. What happened? The encounter / correction / observation triple. One record per invocation, with corrections and folds on top.
  2. What should happen? The recommendation / belief / dialect / verification quad. Communities express opinions; opinions cite evidence; consumers route translations through them.
  3. What does this mean? The vocab + open-enum convention. Slugs are open-enum strings resolved through community-published knowledge graphs.
  4. 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 $type field 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.vocab record 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:

ClassWhat it promisesWhat it allows
IsoBijective; both directions are total inverses.Auto-merge under the lexicon-evolution policy.
InjectionSource embeds in target without loss. Forward is total; backward needs no complement.Auto-merge as forward-only.
ProjectionTarget is a quotient of source; forward drops information. Backward needs the complement.PR review under the policy.
AffinePartial. The forward direction may fail on some inputs.PR review plus a community recommendation.
GeneralNone 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:

CoercionClassWhen
IsoForward and inverse are total inverses (e.g. Int to its decimal string and back).
RetractionForward is total; inverse recovers the forward image only.
ProjectionForward drops information (e.g. Float to Int by truncation).
OpaqueDocumentation 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-test runs GetPut on a corpus.
  • property-test runs an arbitrary boolean predicate.
  • static-check runs 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 kind discriminator (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_to so 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

KindWhat it represents
conceptA SKOS concept; the typical case.
relationA relation type. The node carries the relation's metadata; edges of that kind reference this node by slug.
instanceA specific entity (a did, an at-uri, a real-world identifier).
typeA type-of-types; rare but useful for meta-vocabularies.
collectionA 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).

PropertyMeaning
symmetric
asymmetric
transitive
reflexive for all in scope
irreflexive for all in scope
functional
inverseFunctional
inverseOfA 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:

FieldSKOS counterpart
labelprefLabel
alternateLabelsaltLabel
hiddenLabelshiddenLabel
descriptiondefinition
scopeNotescopeNote
exampleexample
historyNotehistoryNote
editorialNoteeditorialNote
changeNotechangeNote
notationnotation
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 + reflexive subsumed_by walk from source.
  • is_subsumed_by(specific, general) -> bool — the legacy hierarchical query.
  • direct_targets(source, relation) -> &[String] and direct_sources(target, relation) -> &[String] — one-hop neighbours.
  • equivalent_in(source, other) -> Option<String> — find the equivalent slug in another graph via equivalent_to edges.
  • top() / top_with(explicit) — the vocab's top node under subsumed_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> and satisfies(uri, x, relation, y) -> Option<bool> — query a named vocab. None means the registry has no entry for that URI.
  • translate(from_uri, to_uri, slug) -> Option<String> — walk equivalent_to edges 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_to between two vocabs — derives a free lens available through the orchestrator's mapEnum cache.

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.deliberation declares a community-scoped topic. Carries the owning community, an open-enum classification (question / proposal / grievance / ...), an open-enum status (open / closed / tabled / adopted / ...), and an optional pointer to the resulting outcome.
  • dev.idiolect.deliberationStatement is one statement made inside a deliberation. Carries the deliberation it belongs to, the text, an open-enum classification (claim / proposal / dissent / clarification / ...), and an anonymous flag with an optional authoredOn service-DID surrogate.
  • dev.idiolect.deliberationVote is a vote on a statement. Carries the statement (as a strongRef), an open-enum stance (defaults to agree / pass / disagree), an optional weight integer, and an optional rationale.
  • dev.idiolect.deliberationOutcome is an observer-published tally. Carries the deliberation, per-stance counts per statement, the computedAt timestamp, 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, with polar_opposite_of edges).

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.observation record.
  • 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):

MethodFolds
correction-ratePer-lens correction counts grouped by reason.
encounter-throughputEncounter traffic by kind and downstream result.
verification-coveragePer-lens verification counts by kind, result, and distinct verifiers.
lens-adoptionPer-lens encounter count and distinct invokers.
action-distributionEncounter counts grouped by use.action.
purpose-distributionEncounter counts grouped by use.purpose.
basis-distributionRecord counts grouped by basis variant.
attribution-chainsdev.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:

ClassGate behavior
IsoAuto-merge. No governance review.
InjectionAuto-merge as forward-only.
ProjectionPR review required. Complement persistence required.
AffinePR review plus a dev.idiolect.recommendation from a recognised reviewer.
GeneralManual 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 deprecations block as replacement.

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:

EditClassAction
Add node, add edge(no migration needed)Open enums tolerate. Stage 4 corpus regression only.
Remove nodeProjectionFull pipeline.
Rename nodeIsoRenameEdgeName 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.yml runs 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.

SectionContents
CratesOne page per workspace crate, with public types, traits, error variants, and feature flags.
LexiconsOne page per dev.idiolect.* lexicon, with field-by-field shape.
CLIEvery shipped idiolect subcommand, its flags, and its output.
HTTP query APIEvery endpoint exposed by the orchestrator, request and response shape.
Stability and versioningThe 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.

CratePurpose
idiolect-recordsGenerated record types for the dev.idiolect.* lexicons; Record trait; family modules.
idiolect-codegenLexicon-driven Rust + TypeScript emitter; drift gate; breaking-change classifier.
idiolect-lensResolve PanprotoLens records; run apply_lens.
idiolect-identityDID resolution (did:plc, did:web).
idiolect-indexerFirehose consumer with pluggable stream / handler / cursor store.
idiolect-oauthOAuthTokenStore trait and shipped impls.
idiolect-observerFold encounter-family records into observation records.
idiolect-orchestratorRead-only HTTP query API over a record catalog.
idiolect-verifyVerification runners with declarative dispatch.
idiolect-migrateSchema diff plus lens-based record migration.
idiolect-cliCommand-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-records

This 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

TypeFormat
AtUriat-uri
Diddid
Nsidnsid
DatetimeRFC 3339
UriURL
CidCID
LanguageBCP 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:

ModePurpose
DefaultEmit every generated tree.
--checkVerify 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>.rs with the typed record struct, every nested defs type, the Record impl, 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.json produces the orchestrator's HTTP routes (crates/idiolect-orchestrator/src/generated/) and the matching CLI dispatcher (crates/idiolect-cli/src/generated.rs).
  • observer-spec/methods.json produces the observer's method taxonomy (crates/idiolect-observer/src/generated.rs).
  • verify-spec/runners.json produces 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:

  1. NSIDs are ASCII, lowercase, dot-separated. The emitter rejects non-conforming input.
  2. PascalCase names are derived deterministically from a slug. On collision (foo-bar and foo_bar), the second occurrence gets a numeric suffix (FooBar2).
  3. 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 = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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:

TypeBacking store
InMemoryResolverHashMap<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:

FeatureAdds
pds-reqwestReqwestPdsClient (read-only). The reqwest-backed write surface uses SigningPdsWriter plus a DpopProver (one of StaticDpopProver, NoOpDpopProver, or P256DpopProver with the dpop-p256 feature).
pds-atriumAtriumPdsClient.
pds-resolvefetcher_for_did, publisher_for_did — DID-to-PDS resolution helpers. Pulls in idiolect-identity.
dpop-p256The 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-identity

This 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.

TypeFeatureBacking
InMemoryIdentityResolver(always)HashMap<Did, DidDocument>. Tests and fixtures.
ReqwestIdentityResolverresolver-reqwestReqwest-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

FeatureAdds
resolver-reqwestThe 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-indexer

This 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

TypeFeaturePurpose
JetstreamEventStreamfirehose-jetstreamSubscribes to a Jetstream websocket feed.
TappedFirehoseStreamfirehose-tappedSubscribes to the at-proto-native firehose via tapped.
ReconnectingStream<S>reconnectingWraps any S: EventStream with exponential-backoff reconnect.
InMemoryCursorStore(always)HashMap-backed; for tests.
FilesystemCursorStorecursor-filesystemOne JSON file per stream.
SqliteCursorStorecursor-sqliteOne row per stream. Pairs with handlers that also write SQLite.
NoopRecordHandler(always)Counts events and drops them. Useful as a baseline.
RetryingHandler / CircuitBreakerHandlerresilienceWraps an inner handler with retry / circuit-breaker policies.

Error surface

IndexerError flattens the failure modes from all three boundaries. Variants:

VariantTrigger
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

FeatureAdds
firehose-jetstreamJetstream websocket client.
firehose-tappedTapped at-proto-native firehose client.
cursor-filesystemFilesystem cursor store.
cursor-sqliteSQLite cursor store.
reconnectingReconnect wrapper.
resilienceRetry 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 = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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

StoreFeatureBacking
InMemoryOAuthTokenStore(always)HashMap-backed; for tests.
FilesystemOAuthTokenStorestore-filesystemOne JSON file per session.
SqliteOAuthTokenStorestore-sqliteOne 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

FeatureAdds
store-filesystemThe filesystem-backed session store.
store-sqliteThe 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 = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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 nameModuleFolds
correction-ratecorrection_ratePer-lens correction counts grouped by reason.
encounter-throughputencounter_throughputEncounter traffic by kind and downstream result.
verification-coverageverification_coveragePer-lens verification counts by kind, result, and distinct verifiers.
lens-adoptionlens_adoptionPer-lens encounter count and distinct invokers.
action-distributionaction_distributionEncounter counts grouped by use.action (with optional vocab roll-up).
purpose-distributionpurpose_distributionEncounter counts grouped by use.purpose.
basis-distributionbasis_distributionRecord counts grouped by basis variant, bucketed by record kind.
attribution-chainsattribution_chainsCounts 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 implement ObservationMethod.
  • Instance-form methods consume a panproto WInstance plus the NSID and implement InstanceMethod. They wrap into ObservationMethod via InstanceMethodAdapter.

default_methods() returns boxed instances of every record-form method.

Publisher

ObservationPublisher is the persistence boundary. Shipped implementations:

TypeBacking
InMemoryPublisherVec<Observation>. For tests.
PdsPublisherWrites via an idiolect_lens::PdsWriter to the observer's PDS.

Errors

ObserverError is a flattened error type; ObserverResult<T> is its alias.

Feature flags

FeatureAdds
daemonThe idiolect-observer binary plus its CLI. Pulls in tracing-subscriber, anyhow, and the indexer's tapped firehose / sqlite cursor features.
pds-atriumForwards 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 = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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 holding Entry<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 (under catalog-sqlite) — persistent catalog backing.
  • CatalogHandler — the indexer's RecordHandler<IdiolectFamily> impl that upserts every accepted record into the catalog.
  • AppState plus http_router() — axum router wiring under query-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:

PathReturns
GET /healthz, GET /readyzLiveness + readiness.
GET /metricsPrometheus exposition.
GET /v1/statsPer-kind record counts.
GET /v1/bounties/openCataloged 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/recommendationsRecommendations 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

FeatureAdds
catalog-sqliteSQLite-backed catalog store.
query-httpHTTP server (axum-based).
daemonThe 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 = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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:

KindRunner
roundtrip-testRoundtripTestRunner — runs put(get(a)) == a over a corpus.
property-testPropertyTestRunner — runs an arbitrary boolean predicate over a corpus.
static-checkStaticCheckRunner — runs panproto's existence and structural checks against the lens chain.
coercion-lawCoercionLawRunner — 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

  1. Add the kind's entry to verify-spec/runners.json.
  2. Run cargo run -p idiolect-codegen to refresh the generated kind taxonomy.
  3. Implement the VerificationRunner trait against the new kind in a new module under crates/idiolect-verify/src/.
  4. Re-export it from lib.rs and add to the runner registry wiring.

idiolect-migrate

Source: crates/idiolect-migrate/

This crate is publish = false and is not on docs.rs. The authoritative reference is the source above plus the rustdoc built locally with cargo 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 a CompatReport distinguishing compatible from breaking changes.
  • plan_auto(src, tgt, hints) — for breaking diffs that are covered by shipped migration recipes, returns a MigrationPlan carrying source / target schema hashes plus a lens body the caller can publish. For breaking diffs that resist automation, returns Err(PlannerError::NotAutoDerivable) listing the offending changes.
  • migrate_record(lens, source_record, schema_loader) — wraps idiolect_lens::apply_lens for the one-shot case.
  • MigrationPlan — the typed plan struct.
  • MigrateError, MigrateResult, PlannerError — the error types.
  • Re-exported CompatReport and SchemaDiff from panproto-check for convenience.

Migration shapes

DiffBehavior
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:

  1. The migration-shaped API (classify-then-plan-then-migrate) is a different shape than the runtime API (apply_lens plus resolvers).
  2. idiolect-migrate depends on panproto-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 = false and 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:

  1. 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.
  2. 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

SettingDefaultOverride
Orchestrator URLhttp://localhost:8787--url flag on orchestrator subcommands
Log levelinfoRUST_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/.

NSIDPage
dev.idiolect.adapteradapter
dev.idiolect.beliefbelief
dev.idiolect.bountybounty
dev.idiolect.communitycommunity
dev.idiolect.correctioncorrection
dev.idiolect.defsdefs
dev.idiolect.deliberationdeliberation
dev.idiolect.deliberationStatementdeliberationStatement
dev.idiolect.deliberationVotedeliberationVote
dev.idiolect.deliberationOutcomedeliberationOutcome
dev.idiolect.dialectdialect
dev.idiolect.encounterencounter
dev.idiolect.observationobservation
dev.idiolect.recommendationrecommendation
dev.idiolect.retrospectionretrospection
dev.idiolect.verificationverification
dev.idiolect.vocabvocab

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

FieldTypeRequiredNotes
frameworkstring (≤128)yesCanonical framework name (e.g. hasura, prisma, coq).
versionRangestringyesSemver range supported.
invocationProtocolobjectyesHow the adapter is invoked.
isolationobjectyesSandboxing requirements the orchestrator must honour.
authordidyesDID of the adapter author.
verificationat-urinoOptional verification record demonstrating conformance.
occurredAtdatetimeyesPublication timestamp.

invocationProtocol

SubfieldTypeRequiredNotes
kindopen enumyessubprocess / http / wasm.
kindVocabvocabRefnoVocab the kind slug resolves against.
entryPointstringnoBinary name (subprocess), URL (http), or WASM module reference.
inputSchemaschemaRefnoSchema of the adapter's input.
outputSchemaschemaRefnoSchema of the adapter's output.

isolation

SubfieldTypeRequiredNotes
kindopen enumyesnone / process / container / vm / wasm-sandbox.
kindVocabvocabRefnoVocab the kind slug resolves against.
networkPolicyopen enumnonone / egress-denylist / egress-allowlist / full.
networkPolicyVocabvocabRefnoVocab the policy slug resolves against.
filesystemPolicyopen enumnoreadonly / scratch / writable-subtree / full.
filesystemPolicyVocabvocabRefnoVocab the policy slug resolves against.
resourceLimits{ maxMemoryBytes?, maxCpuSeconds?, maxWallSeconds? }noHard 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:

SlugWhat it means
subprocessThe orchestrator forks entryPoint as a child process and pipes JSON over stdin/stdout.
httpThe orchestrator POSTs JSON to the URL at entryPoint.
wasmThe 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:

SlugWhat it means
noneRun in the orchestrator's own process. Only safe for fully-trusted code.
processFork into a separate process; OS-level isolation.
containerRun in a container (Docker, Podman, Firecracker microVM).
vmRun in a full VM.
wasm-sandboxRun 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:

networkPolicyWhat it means
noneNo network access.
egress-denylistNetwork access except to listed denied hosts.
egress-allowlistNetwork access only to listed allowed hosts.
fullUnrestricted.
filesystemPolicyWhat it means
readonlyThe adapter sees a read-only mount.
scratchThe adapter writes to a scratch directory cleaned up after each invocation.
writable-subtreeThe adapter writes to a designated subtree.
fullUnrestricted.

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:

FieldUnit
maxMemoryBytesRAM, in bytes.
maxCpuSecondsCPU time, in seconds.
maxWallSecondsWall-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

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

FieldTypeRequiredNotes
subjectstrongRecordRefyesAT-URI + CID for the record the belief is about.
holderdidnoParty whose attitude is represented. Omit for first-party.
basisbasisnoStructured grounding (load-bearing when holder differs from the repo owner).
annotationsstring (≤4000 graphemes)noNarrative commentary.
visibilityvisibilitynoVisibility scope.
occurredAtdatetimeyesWhen 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:

VariantUse when
basisSelfAssertedThe holder asserted directly with no external grounding claimed. The default when basis is omitted.
basisCommunityPolicyGrounded in a community's published policy.
basisExternalSignalGrounded in something outside ATProto (a license, an external standard, a statement on another network).
basisDerivedFromRecordGrounded 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:

  1. The lens record (in the lens author's PDS).
  2. The encounter or recommendation describing the use (in either the labeler's or the third party's PDS).
  3. A belief record (in the labeler's PDS) with subject pointing at the encounter / recommendation, holder set to the third party's DID, and basis carrying 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

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

FieldTypeRequiredNotes
requesterdidyesWho is requesting.
wantsunionyesExactly one of wantLens / wantVerification / wantAdapter.
constraintsarray (≤64)noStructured constraints the deliverable must satisfy.
rewardobjectno{ summary?, externalRef? }. The substrate does not transact.
eligibilityarray (≤128)noPostfix eligibility tree.
fulfillmentat-urinoOnce fulfilled, points to the deliverable record.
statusopen enumnoopen / claimed / fulfilled / withdrawn.
statusVocabvocabRefnoVocab the status slug resolves against.
basisbasisnoStructured grounding.
occurredAtdatetimeyesPublication timestamp.

The three want shapes

wantLens

Asks for a lens between two schemas.

SubfieldTypeRequiredNotes
sourceschemaRefyesSource schema.
targetschemaRefyesTarget schema.
bidirectionalbooleannoWhether the requested lens must be invertible.

wantVerification

Asks for a verification of a lens.

SubfieldTypeRequiredNotes
lenslensRefyesThe lens to verify.
kindopen enumyesVerification 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.)
kindVocabvocabRefnoVocab the kind slug resolves against.

wantAdapter

Asks for an adapter for a framework.

SubfieldTypeRequiredNotes
frameworkstring (≤128)yesFramework name.
versionRangestringnoSemver range.

The constraint variants

Each entry in constraints is one of:

VariantCaptures
constraintPerformanceA quantitative bound: metric, threshold, comparison direction (lt, le, eq, ge, gt), optional sample size. Examples: p99-latency-ms ≤ 50, error-rate < 0.001.
constraintConformanceA verification kind (and optional specific property) the deliverable must pass.
constraintLicenseAn SPDX expression plus optional allow / deny lists.
constraintDeadlineA datetime deadline plus optional grace seconds.
constraintDependencyA 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:

VariantArityMeaning
eligibilityMemberatomicClaimer is a member of the named community.
eligibilityVerificationForatomicClaimer has published a verification for the named lens property.
eligibilityDidatomicClaimer's DID matches exactly.
eligibilityAndcombinatorConjoin top two on stack.
eligibilityOrcombinatorDisjoin top two on stack.
eligibilityNotcombinatorNegate 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

FieldTypeRequiredNotes
namestring (≤128)yesHuman-readable community name.
descriptionstring (≤2000 graphemes)yesPurpose, norms, scope. Narrative.
membersarray of did (≤500)noInline membership for small communities.
roleAssignmentsarray of roleAssignment (≤500)noSparse role assignments where the role differs from the default.
memberRoleVocabvocabRefnoVocab the role slugs resolve against.
recordHostingopen enumnomember-hosted / community-hosted / hybrid.
appviewEndpointurinoURL of the community AppView when recordHosting is non-default.
membershipRollat-urinoExternal membership record (for communities above ~200 members).
coreSchemasarray of schemaRefnoSchemas the community treats as canonical.
coreLensesarray of lensRefnoLenses the community treats as canonical.
endorsedCommunitiesarray of at-urinoOther communities recognised as legitimate interlocutors. Not transitive.
conventionsarray (≤64) of structured convention variantsnoDecidable subset of community conventions.
conventionsTextstring (≤10000 graphemes)noNarrative conventions: style guides, norms not expressible structurally.
createdAtdatetimeyesPublication timestamp.

roleAssignment

SubfieldTypeRequiredNotes
diddidyesDID of the member.
roleopen enumyesmember / 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:

VariantCaptures
conventionReviewCadenceMaximum business-days expected before a review is posted, with optional scope narrowing.
conventionVerificationReqA verification kind (and optionally a specific property) the community requires before endorsing a lens.
conventionDeprecationPolicyMinimum 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 members is 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 membershipRoll is 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

SlugWhat it means
member-hostedRecords live on individual member PDSes (default ATProto).
community-hostedRecords live on a community AppView, gated by membership (Acorn-style).
hybridBoth. 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

FieldTypeRequiredNotes
encounterencounterRefyesThe encounter whose output was edited.
pathstring (≤1024)yesJSON Pointer or equivalent into the produced output.
originalValueunknownnoValue prior to correction. May be elided for visibility reasons.
correctedValueunknownnoValue after correction.
reasonopen enumyeslens-error / domain-difference / source-error / downstream-idiosyncrasy / user-mistake / retrospective.
reasonVocabvocabRefnoVocab the reason slug resolves against.
rationalestring (≤2000 graphemes)noHuman-readable justification.
holderdidnoParty the correction is attributed to.
basisbasisnoStructured grounding for third-party attribution.
visibilityvisibilityyesVisibility scope.
occurredAtdatetimeyesWhen 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:

SlugWhat it meansImplication for the lens
lens-errorThe lens produced wrong output for the input.Bug in the lens.
domain-differenceThe lens output is correct under one set of conventions; the consumer wants a different set.Not a bug; a different community translation.
source-errorThe source record was wrong; the lens propagated the error faithfully.Not a bug; upstream issue.
downstream-idiosyncrasyThe downstream consumer has an unusual requirement the lens does not target.Not a bug; consumer-specific.
user-mistakeThe user invoked the wrong lens.Not a bug; routing issue.
retrospectiveA 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

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.

SubfieldTypeNotes
uriat-uriAT-URI pointing to a schema record.
cidcid-linkContent hash of the schema.
languagestringSchema-language identifier (atproto-lexicon, postgres-sql, protobuf, graphql, json-schema).

lensRef

SubfieldTypeNotes
uriat-uriAT-URI of a lens record.
cidcid-linkContent hash of the lens.
directionenumunidirectional / bidirectional.

encounterRef

SubfieldTypeNotes
uriat-uri (required)AT-URI of the encounter.
cidcid-linkOptional CID for revision pinning.

vocabRef

SubfieldTypeNotes
uriat-uriAT-URI of a vocab record.
cidcid-linkContent hash pinning a specific vocab revision.

strongRecordRef

SubfieldTypeRequiredNotes
uriat-uriyesAT-URI of the referenced record.
cidcid-linkyesContent hash.

Parallel to com.atproto.repo.strongRef. Repeated here so the defs tree is self-contained.

tool

SubfieldTypeRequiredNotes
namestringyesCanonical tool name (panproto, coq, tlaplus, z3, nextest).
versionstringyesVersion string.
commitstringnoOptional source commit or build identifier.

visibility

A closed-enum string. Five values:

ValueMeaning
public-detailedFull record body published.
public-minimalRecord published with elided detail (e.g. omits source instance).
public-aggregate-onlyRecord consumed only by aggregators; individual reads suppressed.
community-scopedReserved for v1 substrate enforcement; should not be served to parties outside the named community once enforcement lands.
privateShould 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.

SubfieldTypeRequiredNotes
actionstring (≤256)yesAction identifier, resolved against actionVocabulary.
materialmaterialSpecnoWhat is being acted on.
purposestring (≤256)noThe end the action serves.
actorstring (≤256)noWho performs or benefits.
actionVocabularyvocabRefnoVocab the action slug resolves against.
purposeVocabularyvocabRefnoVocab the purpose slug resolves against.

materialSpec

SubfieldTypeNotes
scopestring (≤256)Community-defined scope (classroom_materials, production_logs, scraped_corpus).
uriuriOptional pointer to a specific dataset.

lensProperty

A union covering the seven verification kinds. Each verification record carries one of these as its property field.

VariantUsed by
lpRoundtripkind: roundtrip-test
lpGeneratorkind: property-test
lpTheoremkind: formal-proof
lpConformancekind: conformance-test
lpCheckerkind: static-check
lpConvergencekind: convergence-preserving
lpCoercionLawkind: coercion-law

lpRoundtrip

SubfieldTypeRequiredNotes
domainstring (≤512)yesSymbolic description of the input set.
generatorurinoOptional pointer to a generator that enumerates the domain.

lpGenerator

SubfieldTypeRequiredNotes
specstring (≤2000)yesGenerator specification (proptest Strategy reference, Hypothesis strategy, QuickCheck Arbitrary).
runnerstringnoName of the PBT runtime.
seedintegernoOptional seed for reproducibility.

lpTheorem

SubfieldTypeRequiredNotes
statementstring (≤4000)yesThe theorem, in the declared system syntax.
systemstringnoProof system (coq, lean4, agda, tlaplus, z3).
freeVariablesarray of stringsnoNames of free variables.

lpConformance

SubfieldTypeRequiredNotes
standardstringyesStandard identifier (iso-8601, rfc-3339, en-pos-v2.1).
versionstringyesStandard version.
clausesarray of stringsnoOptional subset of the standard's clauses.

lpChecker

SubfieldTypeRequiredNotes
checkerstringyesStatic-checker identifier (panproto-check, clippy, tsc-strict).
rulesetstringnoNamed ruleset or configuration preset.
versionstringnoChecker version.

lpConvergence

SubfieldTypeRequiredNotes
propertystring (≤1000)yesSymbolic name or description of the preserved property.
boundStepsintegernoOptional bound on steps to fixpoint.

lpCoercionLaw

SubfieldTypeRequiredNotes
standardstring (≤256)yesIdentifier of the coercion-law standard.
versionstring (≤64)noOptional version.
violationThresholdintegernoCap on the violations a runner may report before falsifying.

evidence

A union of structured witnesses for retrospection findings.

VariantUsed when finding kind is
evidenceDivergencemerge-divergence
evidenceLossdata-loss
evidenceMismatchreconciliation-mismatch

evidenceDivergence

SubfieldTypeRequiredNotes
pathAarray of lensRefyesLenses composed in path A.
pathBarray of lensRefyesLenses composed in path B.
witnessInputcid-linknoOptional CID where the two paths diverge.

evidenceLoss

SubfieldTypeRequiredNotes
sourceFieldstringyesDotted path identifying the lost field.
targetSchemaschemaRefnoTarget schema where the loss was observed.
witnessInputcid-linknoWitness input.

evidenceMismatch

SubfieldTypeRequiredNotes
leftRecordcid-linknoLeft record.
rightRecordcid-linknoRight record.
expectedEqualityOnstringnoDotted path or projection under which equality was expected.

caveat

SubfieldTypeRequiredNotes
modestringyesShort failure-mode identifier.
affectsarray of stringsnoDotted paths or field names.
severityenumnoinfo / warn / error.

basis

A union of structured grounds for an attitudinal claim.

VariantUse when
basisSelfAssertedThe holder asserts directly. The default when basis is omitted.
basisCommunityPolicyGrounded in a community's published policy. Carries community (at-uri) and optional policyUri.
basisExternalSignalGrounded in something outside ATProto. Carries url, optional signalType, optional description.
basisDerivedFromRecordGrounded 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

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

FieldTypeRequiredNotes
owningCommunityat-uriyesThe community whose membership is deliberating.
topicstring (≤200 graphemes)yesHuman-readable topic or question.
descriptionstring (≤1000 graphemes)noExtended framing or context.
authRequiredboolean (default true)noWhether participation requires authenticated membership.
classificationopen enumnoquestion / proposal / grievance / retrospective.
classificationVocabvocabRefnoVocab the classification slug resolves against.
statusopen enumnoopen / closed / tabled / adopted / rejected.
statusVocabvocabRefnoVocab the status slug resolves against.
closedAtdatetimenoWhen the deliberation moved out of an open status.
outcomeat-urinoPointer to a deliberationOutcome record summarising the resolved stance.
createdAtdatetimeyesPublication 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

SlugWhat it means
questionAn open question without a proposed resolution.
proposalA specific proposal under consideration.
grievanceA complaint or dispute.
retrospectiveA 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

SlugWhat it means
openActive. Statements and votes accepted.
closedNo longer accepting input. May or may not have an outcome.
tabledClosed but explicitly deferred for later.
adoptedClosed with a positive resolution.
rejectedClosed 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

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

FieldTypeRequiredNotes
deliberationstrongRecordRefyesAT-URI + CID for the deliberation this statement participates in.
textstring (≤400 graphemes)yesStatement text.
classificationopen enumnoclaim / proposal / dissent / clarification / question.
classificationVocabvocabRefnoVocab the classification slug resolves against.
anonymousboolean (default false)noWhether the statement was submitted anonymously.
createdAtdatetimeyesPublication 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

SlugWhat it captures
claimAn assertion of fact or opinion.
proposalA specific proposed action.
dissentAn objection to a prior statement or to the deliberation framing.
clarificationA request for or provision of clarification.
questionAn 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

FieldTypeRequiredNotes
subjectstrongRecordRefyesAT-URI + CID for the statement being voted on.
stanceopen enumyesagree / pass / disagree.
stanceVocabvocabRefnoVocab the stance slug resolves against.
weightinteger ∈ [0, 1000]noOptional ranking signal. Convention: scaled by 1000 for the 0.0–1.0 range.
rationalestring (≤500 graphemes)noOptional narrative reason.
createdAtdatetimeyesPublication 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:

SlugMeaning
agreeAffirms the statement.
passAbstains.
disagreeRejects 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

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

FieldTypeRequiredNotes
deliberationstrongRecordRefyesAT-URI + CID for the deliberation.
statementTalliesarray (≤4096) of statementTallyyesPer-statement vote counts.
adoptedarray (≤256) of strongRecordRefnoStatements the community adopted.
stanceVocabvocabRefnoVocab the per-tally stance slugs resolve against.
computedAtdatetimeyesWhen the observer computed this tally.
tooltoolnoIdentity and version of the aggregator.
occurredAtdatetimeyesPublication timestamp.

statementTally

SubfieldTypeRequiredNotes
statementstrongRecordRefyesThe statement these counts aggregate.
countsarray (≤64) of stanceCountyesPer-stance vote counts.
weightedCountsarray (≤64) of stanceCountnoPer-stance weighted vote counts (when votes carried weight). Scaled by 1000.

stanceCount

SubfieldTypeRequiredNotes
stancestring (≤256)yesStance slug, resolved through the outcome's stanceVocab.
countnon-negative integeryesVote 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 tool field.
  • 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 mapEnum lens (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

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

FieldTypeRequiredNotes
owningCommunityat-uriyesThe community that owns this dialect.
namestring (≤128)yesHuman-readable dialect name.
descriptionstring (≤4000 graphemes)noPurpose and scope.
idiolectsarray of schemaRefnoSchemas that constitute the dialect's idiolect set.
preferredLensesarray of lensRefnoTranslations the community prefers.
deprecationsarray of DeprecationnoDeprecated entries with replacement pointers.
versionstringnoDialect version (semver when applicable).
previousVersionat-urinoPredecessor revision in a version chain.
createdAtdatetimeyesPublication timestamp.

Deprecation

SubfieldTypeRequiredNotes
refat-uriyesThe deprecated idiolect or lens.
replacementat-urinoOptional successor.
deprecatedAtdatetimeyesWhen the deprecation took effect.
reasonstring (≤1000 graphemes)yesWhy 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

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

FieldTypeRequiredNotes
lenslensRefyesThe lens that was invoked.
sourceSchemaschemaRefyesSource schema the lens translated from.
targetSchemaschemaRefnoTarget schema produced by the lens. Often implied by the lens; elided when unambiguous.
sourceInstancecid-linknoContent-addressed reference to the source instance. Omit when visibility restricts publishing source data.
producedOutputcid-linknoContent-addressed reference to the produced output.
useuseyesStructured action / material / purpose / actor.
downstreamResultopen enumnosuccess / corrected / rejected / unknown.
downstreamResultVocabvocabRefnoVocab the slug resolves against.
annotationsstring (≤4000 graphemes)noNarrative commentary.
holderdidnoParty the encounter is attributed to. Omit for first-party records.
basisbasisnoStructured grounding when holder differs from the repo owner.
kindopen enumyesCorpus-kind slug (invocation-log, curated, roundtrip-verified, production, adversarial).
kindVocabvocabRefnoVocab the kind slug resolves against.
visibilityvisibilityyespublic-detailed / public-minimal / public-aggregate-only / community-scoped / private.
occurredAtdatetimeyesWhen the invocation happened. Distinct from the record's createdAt.

Field details

use

The structured payload. A use carries:

  • action (open-enum slug, resolved against actionVocabulary).
  • material (a materialSpec: scope plus optional corpus pointer).
  • purpose (open-enum slug, resolved against purposeVocabulary).
  • 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:

SlugMeaning
invocation-logA real production invocation.
curatedA hand-picked sample, often used for evaluation.
roundtrip-verifiedAn invocation where put(get(a)) == a was verified at write time.
productionSynonym for invocation-log in some pipelines; exists for distinct trust weighting.
adversarialAn 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:

SlugMeaning
successThe output was accepted unchanged.
correctedThe output was edited; a dev.idiolect.correction record exists or is expected.
rejectedThe output was unusable.
unknownThe 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

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

FieldTypeRequiredNotes
observerdidyesDID of the observer publishing this aggregate.
methodobjectyes{ name, description?, codeRef?, parameters? }. The aggregator identity and configuration.
scopeobjectyesThe set of records the observation aggregates over.
outputunknownyesMethod-defined payload (counts, scores, diagnostic summaries).
versionstringyesMethod version. Different versions may produce non-comparable outputs.
basisbasisnoGrounding when the observer is not the repo owner.
visibilityvisibilityyesVisibility scope.
occurredAtdatetimeyesWhen the observation was published.

method

SubfieldTypeRequiredNotes
namestring (≤128)yesShort method identifier.
descriptionstring (≤4000 graphemes)noNarrative method description.
codeRefat-urinoReference to the method's source or specification.
parametersunknownnoFree-form JSON, observer-defined.

scope

SubfieldTypeRequiredNotes
lensesarray of lensRefnoLenses included; empty or omitted means "all".
communitiesarray of at-urinoCommunities whose records are in scope.
encounterKindsarray of open-enum slugsnoEncounter kinds weighted in the aggregation.
encounterKindsVocabvocabRefnoVocab the kind slugs resolve against.
window{ from?, until? }noTime 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

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

FieldTypeRequiredNotes
issuingCommunityat-uriyesCommunity publishing the recommendation.
conditionsarray (≤128) of ConditionyesStructured applicability predicate. Empty array means "always applies".
preconditionsarray (≤128) of ConditionnoAdditional structured assumptions the consumer must verify.
lensPatharray (≥1) of lensRefyesOrdered sequence of lenses to compose.
annotationsstring (≤8000 graphemes)noNarrative explanation.
requiredVerificationsarray of lensPropertynoSpecific properties the recommendation assumes.
caveatsarray (≤32) of CaveatnoStructured failure-mode list.
caveatsTextstringnoNarrative companion to caveats.
basisbasisnoStructured grounding for the attitudinal claim.
supersedesat-urinoPrior recommendation this one replaces.
occurredAtdatetimeyesPublication timestamp.

The condition tree

conditions and preconditions are postfix-operator trees over the combinator set defined inline in this lexicon:

VariantArityPurpose
conditionSourceIsatomicMatch invocations whose source schema equals the named at-uri.
conditionTargetIsatomicMatch invocations whose target schema equals the named at-uri.
conditionActionSubsumedByatomicMatch invocations whose use.action is subsumed by the named slug in the named action vocabulary.
conditionPurposeSubsumedByatomicMatch invocations whose use.purpose is subsumed by the named slug.
conditionDataHasatomicMatch invocations whose data carries the named community-defined property identifier (e.g. length>1024, contains-pii, multilingual).
conditionAndcombinatorPop the top two predicates and conjoin them.
conditionOrcombinatorPop the top two predicates and disjoin them.
conditionNotcombinatorPop 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:

  1. Querying the verifier registry for verification records on each lens in the path.
  2. Confirming that each requiredVerification is covered by an accepted record (signed by a trusted verifier, with result: "holds").
  3. 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:

SubfieldTypeNotes
modestringShort failure-mode identifier (e.g. loses-dialect-markers).
affectsarray of stringsDotted paths or field names the caveat applies to.
severityenuminfo / 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

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

FieldTypeRequiredNotes
encounterencounterRefyesThe encounter being retrospected.
findingobjectyes{ kind, kindVocab?, detail, evidence? }.
latencyinteger (seconds)nodetectedAt - encounter.occurredAt. Precomputed for aggregation.
detectingPartydidyesDID of the party that detected the issue.
confidencenumber ∈ [0, 1]noOptional confidence score.
disputedAttributionbooleannoThe detecting party's hint that the causal claim may be contested.
basisbasisnoStructured grounding.
detectedAtdatetimeyesWhen the issue was detected.
occurredAtdatetimeyesWhen this retrospection record was published.

finding

SubfieldTypeRequiredNotes
kindopen enumyesmerge-divergence / data-loss / reconciliation-mismatch / other.
kindVocabvocabRefnoVocab the kind slug resolves against.
detailstring (≤8000 graphemes)yesNarrative detail.
evidenceunion of evidenceDivergence / evidenceLoss / evidenceMismatchnoStructured 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

SlugEvidence shapeWhat it captures
merge-divergenceevidenceDivergence (paths A and B + witness input)Two translation paths that should have converged but produced different outputs.
data-lossevidenceLoss (source field path + target schema + witness input)A source-schema field unrepresented in the target after translation.
reconciliation-mismatchevidenceMismatch (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

FieldTypeRequiredNotes
lenslensRefyesThe lens whose property is being asserted.
kindopen enumyesroundtrip-test / property-test / formal-proof / conformance-test / static-check / convergence-preserving / coercion-law.
kindVocabvocabRefnoVocab the kind slug resolves against.
verifierdidyesDID of the party asserting the verification.
tooltoolyesTool identity and version.
propertyunion of seven lensProperty shapesyesStructured statement of what is being asserted.
resultopen enumyesholds / falsified / inconclusive.
resultVocabvocabRefnoVocab the result slug resolves against.
counterexamplecid-linknoFor result: falsified: minimal counterexample.
dependenciesarray of at-urinoOther verifications this one depends on (e.g. a proof assuming a lemma).
proofArtifactcid-linknoFor kind: formal-proof: checkable proof artifact (Coq / Lean / Agda term).
basisbasisnoStructured grounding when relevant.
occurredAtdatetimeyesWhen 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.

KindProperty shapeWhat the runner does
roundtrip-testlpRoundtrip (domain string + optional generator URI)Run put(get(a)) == a on samples drawn from the domain.
property-testlpGenerator (spec + runner identifier + seed)Run an arbitrary boolean predicate over generator samples.
formal-prooflpTheorem (statement in proof-system syntax + system + free variables)Check the proof artifact in the named system.
conformance-testlpConformance (standard identifier + version + clause subset)Run the standard's conformance suite.
static-checklpChecker (checker identifier + ruleset + version)Run the checker against the lens chain.
convergence-preservinglpConvergence (property + optional step bound)Verify the property is preserved under repeated application (fixpoint, reconciliation).
coercion-lawlpCoercionLaw (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

SlugMeaning
holdsThe runner did not falsify the property within its budget.
falsifiedThe runner found a counterexample.
inconclusiveThe 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

dev.idiolect.vocab

A community-published vocabulary. Two compatible shapes are supported:

  1. The legacy single-relation tree (actions + top + world), where every entry declares its direct subsumers and the world pins the inference discipline.
  2. 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

FieldTypeRequiredNotes
namestring (≤128)yesHuman-readable name.
descriptionstring (≤4000 graphemes)noNarrative description.
worldenumyesclosed-with-default / open / hierarchy-closed. Default subsumption discipline.
defaultRelationat-urinoPointer to the relation-kind node consumers should treat as canonical when no relation is specified.
topstring (≤256)noIdentifier of the vocabulary's top action under subsumed_by. Required when actions is populated and world=closed-with-default.
actionsarray (≤4096) of actionEntrynoLegacy tree shape.
nodesarray (≤4096) of vocabNodenoGraph shape.
edgesarray (≤16384) of vocabEdgenoGraph shape.
supersedesat-urinoPrior vocabulary this one replaces.
occurredAtdatetimeyesPublication timestamp.

The world discipline

A closed-enum field naming how undeclared identifiers are treated:

SlugBehavior
closed-with-defaultEvery undeclared id is subsumed by top and nothing else. The natural default for hierarchical taxonomies.
openUndeclared ids are incomparable to every declared id. Right when the vocab is one community's view of an open-ended space.
hierarchy-closedOnly 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)

FieldTypeRequiredNotes
idstring (≤256)yesStable action identifier.
parentsarray of stringsyesDirect subsumers. Empty for top.
classstring (≤256)noIdentifier of the attitudinal composition this action instances.
descriptionstring (≤500 graphemes)noOptional human-readable description.

The vocabNode (graph form)

FieldTypeRequiredNotes
idstring (≤256)yesStable slug. Used as edge endpoint.
kindopen enumnoconcept / relation / instance / type / collection.
kindVocabvocabRefnoVocab the kind slug resolves against.
subkindUriat-urinoPointer to another node typing this node's subkind.
labelstring (≤500)noPrimary human-readable label (SKOS prefLabel).
alternateLabelsarray (≤50)noSKOS altLabel.
hiddenLabelsarray (≤50)noSKOS hiddenLabel: searchable, not displayed.
descriptionstring (≤2000 graphemes)noSKOS definition.
scopeNotestring (≤2000 graphemes)noSKOS scopeNote: usage guidance.
examplestring (≤2000 graphemes)noSKOS example.
historyNotestring (≤2000 graphemes)noSKOS historyNote.
editorialNotestring (≤2000 graphemes)noSKOS editorialNote.
changeNotestring (≤2000 graphemes)noSKOS changeNote.
notationstring (≤500)noSKOS notation: non-text identifier.
externalIdsarray (≤20) of externalIdnoMappings to external knowledge bases.
statusopen enumnoproposed / active / deprecated.
relationMetadatarelationMetadatanoOWL Lite property characteristics. Required for kind: relation.

vocabNode.kind

SlugMeaning
conceptA SKOS concept; the typical case.
relationThis node represents a relation type. Edges with this slug reference this node.
instanceAn individual; a specific entity.
typeA metaclass; a type-of-types.
collectionA SKOS Collection. Members linked via member_of edges.

The vocabEdge

FieldTypeRequiredNotes
sourcestringyesSource node id.
targetstringyesTarget node id.
relationSlugstringyesRelation slug. References a relation-kind node.
metadataunknownnoEdge metadata, free-form.

OWL Lite property characteristics

A relation-kind node carries metadata declaring algebraic properties:

PropertyMeaning
symmetric
asymmetric
transitive
reflexive for all in scope
irreflexive for all in scope
functional
inverseFunctional
inverseOfPointer to the inverse relation node.
worldPer-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:

FieldSKOS counterpart
labelprefLabel
alternateLabelsaltLabel
hiddenLabelshiddenLabel
descriptiondefinition
scopeNotescopeNote
exampleexample
historyNotehistoryNote
editorialNoteeditorialNote
changeNotechangeNote
notationnotation
externalIds[]exactMatch / closeMatch / broadMatch / narrowMatch / relatedMatch

External-id mappings

Each externalId carries:

SubfieldTypeNotes
systemstringKnowledge-base identifier (wikidata, ror, orcid, lcsh, ...).
idstringIdentifier in that system.
matchenumexact / 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 elementGraph element
actionEntryvocabNode { id, kind: "concept" }
Each parent in actionEntry.parentsvocabEdge { source = entry.id, target = parent, relationSlug: "subsumed_by" }
actionEntry.class (when present)vocabEdge { source = entry.id, target = class, relationSlug: "instance_of" }
topThe 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

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):

CommandCalls
idiolect orchestrator adapters --framework <NAME>GET /v1/adapters?framework=...
idiolect orchestrator bountiesGET /v1/bounties/open
idiolect orchestrator bounties --requester_did <DID>GET /v1/bounties/by-requester?requester_did=...
idiolect orchestrator recommendationsGET /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

PathReturns
GET /healthz200 OK if the process is alive.
GET /readyz200 OK once the catalog has caught up.
GET /metricsPrometheus exposition.
GET /v1/statsPer-kind record counts.

Generated query endpoints

REST pathxrpc pathReturns
GET /v1/bounties/open/xrpc/dev.idiolect.query.openBountiesBounties whose status is open, claimed, or unset.
GET /v1/bounties/want-lens?.../xrpc/dev.idiolect.query.bountiesForWantLensBounties whose wants is a specific lens.
GET /v1/bounties/by-requester?requester_did=.../xrpc/dev.idiolect.query.bountiesByRequesterBounties by requester DID.
GET /v1/adapters?framework=.../xrpc/dev.idiolect.query.adaptersForFrameworkAdapters declared for a framework.
GET /v1/adapters/by-invocation-protocol?.../xrpc/dev.idiolect.query.adaptersByInvocationProtocolAdapters by invocation-protocol kind.
GET /v1/adapters/with-verification?.../xrpc/dev.idiolect.query.adaptersWithVerificationAdapters that carry at least one verification record.
GET /v1/recommendations/xrpc/dev.idiolect.query.recommendationsStartingFromRecommendations starting from a given source schema.
GET /v1/verifications?lens_uri=.../xrpc/dev.idiolect.query.verificationsForLensVerifications for a specific lens.
GET /v1/verifications/by-kind?.../xrpc/dev.idiolect.query.verificationsByKindVerifications by kind.
GET /v1/communities?.../xrpc/dev.idiolect.query.communitiesForMemberCommunities for a member DID.
GET /v1/communities/by-name?.../xrpc/dev.idiolect.query.communitiesByNameCommunities by name.
GET /v1/dialects/for-community?.../xrpc/dev.idiolect.query.dialectsForCommunityDialects owned by a community.
GET /v1/beliefs/about?.../xrpc/dev.idiolect.query.beliefsAboutRecordBeliefs whose subject is a given record.
GET /v1/beliefs/by-holder?.../xrpc/dev.idiolect.query.beliefsByHolderBeliefs by holder DID.
GET /v1/vocabularies/by-world?.../xrpc/dev.idiolect.query.vocabulariesWithWorldVocabularies declared with a given world.
GET /v1/vocabularies/by-name?.../xrpc/dev.idiolect.query.vocabulariesByNameVocabularies 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 / SchemaLoader Send 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 v1 prefix 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 in dev.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-compat gate flips from advisory to a hard fail.
  • The HTTP API's v1 prefix becomes a stability commitment; new endpoints are additive.
  • Trait signatures in idiolect-records, idiolect-lens, idiolect-indexer, and idiolect-orchestrator become 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:

BucketContents
AddedNew features.
ChangedBehavior changes; trait surface tightenings; lexicon shape changes.
DeprecatedFeatures that still work but are scheduled for removal.
RemovedFeatures that are gone.
FixedBug fixes for behavior introduced in earlier versions.
SecuritySecurity-relevant fixes.

The Changelog is in CHANGELOG.md.

Compatibility matrix

ComponentSource of truthLock at
idiolect-recordscrates.ioexact version
@idiolect-dev/schemanpmexact version
idiolect CLIbinary release on GitHubrelease tag
idiolect-orchestrator containerghcr.io/idiolect-dev/orchestratorimage SHA
idiolect-observer containerghcr.io/idiolect-dev/observerimage SHA

The container images are sigstore-signed; verification policy is in docs/ci-cd.md.