's recent Leaflet post on treating user intents as living data makes a super interesting point that I think we can push even further.

User Intents as Living Data — A Descriptive Meta-Framework
A response to Bluesky's User Intents proposal: instead of prescribing a fixed set of intent categories, treat them as living data and use panproto lenses to map between the vocabularies that different communities will inevitably create.
https://leaflet.pub/p/did:plc:3vdrgzr2zybocs45yfhcr6ur/3miowxyaoak2c

The basic observation, as I understand it, is that Bluesky's user intents proposal gets something important right—it gives users a machine-readable way to declare preferences about their content—but makes a design choice that will become increasingly hard to maintain as the space of possible uses grows: Bluesky’s proposal prescribes a small, curated set of intent categories (syntheticContentGenerationpublicAccessArchivebulkDatasetprotocolBridging), each modeled as a three-valued toggle.

Blaine’s alternative is descriptive rather than prescriptive: rather than enumerating categories from above, observe the intent mechanisms already in the wild—robots.txt, Creative Commons, AIPREF, platform-specific settings—and formalize the relationships between those vocabularies. The mechanism he proposes for that translation is panproto lenses.

GitHub - panproto/panproto: One engine for schematic version control within and across any schema language.
One engine for schematic version control within and across any schema language. - panproto/panproto
https://github.com/panproto/panproto

I think this framing is exactly right,1 and I think it can be pushed even further by (i) noting that these intent declarations are actually instances of a broader concept; and then (ii) looking at how the structure of this broader concept can be made to do useful work for the consumers of those declarations—and indeed, any kind of declaration that has the same general shape.

The basic idea is that there is a natural framework for intents (and beyond) that draws on a long tradition of studying how people express and reason about beliefs, desires, intentions, permissions, and prohibitions, and that this framework can be made concrete using panproto's theory and lens DSLs.

The challenge

To make the issue tangible, consider a scenario that will be familiar to anyone who has followed the recent discussion around AI training on social media data. A user on Bluesky wants to express the following preferences about their content: for instance, maybe they want to (i) permit archival by public libraries and web archives; (ii) prohibit the use of their posts as training data for commercial AI models; and (iii) allow non-commercial academic researchers to use their photos for fine-tuning but not for pre-training. Under the current user intents proposal, the best they can do is set the allow field of syntheticContentGeneration to false and the allow field of publicAccessArchive to true. The third preference is simply inexpressible, because the proposal provides no mechanism for conditioning a preference on the purpose of the use, the identity of the actor, or the specific action being performed on the content.

This might seem like a problem you could fix by adding more categories. And indeed, in a recent Leaflet post, proposes a finer-grained lexicon schema, community.lexicon.preference.ai, that decomposes AI usage into distinct subcategories and adds a scoped override mechanism on top of the base proposal.

Signaling AI Preferences on ATProto - Nick's Blog
Atproto users need a way to express granular AI preferences and carve out exceptions for specific entities or content types. This post introduces community.lexicon.preference.ai, a lexicon schema that decomposes AI usage into distinct categories and adds a scoped override mechanism built on top of Bluesky's User Intents proposal.
https://ngerakines.leaflet.pub/3miowjw5c222y

This is definitely an improvement; but it also illustrates the underlying challenge: every new distinction (pre-training vs. fine-tuning vs. RAG retrieval vs. embedding generation) requires a governance decision about whether it belongs in the canonical set, and different communities will inevitably carve the space differently. The AI safety community, the digital preservation community, the content licensing community, and academic researchers all have different notions for what "using someone's data" means and what matters about a given use.

So what Blaine's post points toward, and what I want to hopefully make precise here, is that the right response is not to try to anticipate all possible categories but to provide a formal mechanism for defining structured categories, composing them, and translating between different communities' vocabularies, with an explicit accounting of what is lost in translation.

Why attitudes are the right model

The reason I think this framework should draw on existing theory rather than being built from scratch is that what a user intent declaration does already has a name. When a user publishes a preference, they are basically saying: "I (the user) permit / prohibit / want / intend (some structured description of a use of my data)." That is, a user intent declaration is a statement that a person stands in some relationship—permission, prohibition, desire, intention—to some content.2

And this correspondence to existing theory goes further than you might expect. Linguistics, philosophy, and AI have all studied exactly this structure and decomposed it into reusable parts.3 The basic idea is that the words people use to talk about attitudes—often (but not always) verbs, like thinkwantforbidpermitpromiseregret—have meanings that decompose into a small number of recurring, composable components. Some are about belief4 (what someone thinks is true), some about desire5 (what they want), some about intention6 (what they are committed to doing), and some about permission and prohibition7 (what they allow or forbid). Others track the evidence for a claim,8 the evaluation9 of whether something is good or bad, the communicative act10 of saying or declaring something, or the presuppositions11 that are taken for granted.

Crucially, each component is imbued with content by rules of inference associated with that component. These rules let a consumer derive new commitments from declared ones, the way modus ponens lets you derive Q from P and P → Q.

For instance, permission and prohibition might both be associated with a rule of closure under subsumption:12 a user who permits "training of generative image models" thereby permits training of Stable Diffusion in particular, and a user who prohibits "commercial use" thereby prohibits any specific commercial use.13

Different attitudes have crucially distinct content. For instance, belief and desire come apart on closure under entailment. If a consumer represents a user as believing their work is in LAION-5B, and as believing LAION-5B was used to train Stable Diffusion, the consumer is committed to representing them as believing their work helped train Stable Diffusion—i.e. belief inherits its logical consequences.14

Desire is different: a user who wants their photos kept out of a particular training dataset does not thereby want every entailment of that exclusion—the licensing arrangement for whatever photos get substituted in, the marginal differences in the resulting model’s behavior on inputs unrelated to their content, the specific engineering decisions about how to handle the gap. The user has no settled preferences about most of these even though they are strict consequences of what was requested, and a consumer that closed desire under entailment would attribute attitudes the user never expressed.

These inference rules are what make the components useful as building blocks: they are what a consumer actually uses when it reasons about a user's declarations. The same components appear independently in the BDI architecture in AI,15 in episodic logic,16 in speech act theory, and in deontic logic; and the fact that similar small sets show up across independent fields suggests they are tracking something important about how agents relate to content, and that they might provide useful building blocks for a formal system of user intents.

Making it concrete: interfaces, implementations, and records

So let me make this concrete with actual panproto theory DSL definitions. The basic idea is that the framework has three levels, and they map directly onto familiar programming concepts.

The first level is interfaces.17 An interface specifies what kinds of things exist (holders, content, attitudes) and what operations you can perform on them, without pinning down what concrete values those kinds contain. The second level is implementations of those interfaces—they fill in the abstract kinds with actual sets of values, in the same way that a class implementing an interface fixes the types of its fields. And the third level is records: individual data entries asserting that a specific user stands in a specific relationship (permission, prohibition, etc.) to a specific structured description of a use. These are the things that actually live in a user's ATProto repo.

To be concrete about how these levels connect to ATProto: the first two levels—interfaces and implementations—are panproto's domain. They define the schema for attitude records: what fields exist, what types they have, and what equations hold between operations like subsumption. The third level is ATProto's domain. Each implementation corresponds to a Lexicon record type (identified by an NSID like dev.attitudes.intent.use_prohibition), and instances of that type are ordinary ATProto records stored in a user's repo, created and queried through the standard com.atproto.repo.* XRPC endpoints. So the YAML definitions below are what panproto compiles to produce the schema; the JSON records later in the post are what ATProto actually stores and serves.

Building-block definitions

Each building block is a small definition. So here's what the root might look like—it just says that there is a holder, content, and a declaration relating them:

# theories/att.yaml
id: dev.attitudes.att
description: >
  Root theory for all attitudes. Every attitude involves a holder
  standing in some relation to some content.
theory: ThAtt
sorts:
  - name: Holder
  - name: Content
  - name: Att
    params:
      - { name: h, sort: Holder }
      - { name: c, sort: Content }
ops:
  - { name: holder, input: Att, output: Holder }
  - { name: content, input: Att, output: Content }

Note that Content is intentionally unstructured at this level; it gets its internal structure from a content definition that is merged with the attitude definition, as we will see below.

And here's a possible building block for permission:

# theories/permissive.yaml
id: dev.attitudes.permissive
description: >
  Permission extraction. The attitude yields a well-formed permission.
theory: ThPermissive
sorts:
  - name: Holder
  - name: Content
  - name: Att
    params:
      - { name: h, sort: Holder }
      - { name: c, sort: Content }
  - name: Permission
    params:
      - { name: h, sort: Holder }
      - { name: c, sort: Content }
ops:
  - { name: holder, input: Att, output: Holder }
  - { name: content, input: Att, output: Content }
  - { name: permission_extract, input: Att, output: Permission }

And a prohibition block along the same lines:

# theories/prohibitive.yaml
id: dev.attitudes.prohibitive
description: >
  Prohibition extraction. Dual of ThPermissive.
theory: ThProhibitive
sorts:
  - name: Holder
  - name: Content
  - name: Att
    params:
      - { name: h, sort: Holder }
      - { name: c, sort: Content }
  - name: Prohibition
    params:
      - { name: h, sort: Holder }
      - { name: c, sort: Content }
ops:
  - { name: holder, input: Att, output: Holder }
  - { name: content, input: Att, output: Content }
  - { name: prohibition_extract, input: Att, output: Prohibition }

But crucially, the content of an attitude is itself structured.18 For user intents about data use, we could define a content definition called ThUse that gives the content kinds for actions, materials, purposes, and actors, together with a hierarchy over actions that says which actions are "bigger than" (subsume) which others:19

# theories/use.yaml
id: dev.attitudes.intent.use
description: >
  Content theory for structured data-use descriptions.
  Actions form a subsumption hierarchy: train_model subsumes
  ingest and transform, so a prohibition on training covers
  the constituent steps.
theory: ThUse
sorts:
  - name: Use
  - name: Action
  - name: Material
  - name: Purpose
  - name: Actor
ops:
  - { name: action, input: Use, output: Action }
  - { name: material, input: Use, output: Material }
  - { name: purpose, input: Use, output: Purpose }
  - { name: actor, input: Use, output: Actor }
  - name: subsumes
    inputs: [{ name: a, sort: Action }, { name: b, sort: Action }]
    output: Bool
  # action constants
  - { name: ingest, inputs: [], output: Action }
  - { name: transform, inputs: [], output: Action }
  - { name: archive, inputs: [], output: Action }
  - { name: train_model, inputs: [], output: Action }
  - { name: fine_tune, inputs: [], output: Action }
  - { name: run_inference, inputs: [], output: Action }
  - { name: reuse, inputs: [], output: Action }
  # purpose constants
  - { name: commercial, inputs: [], output: Purpose }
  - { name: non_commercial, inputs: [], output: Purpose }
  - { name: academic, inputs: [], output: Purpose }
  - { name: any_purpose, inputs: [], output: Purpose }
equations:
  # reflexivity: every action subsumes itself
  - name: subsumes_refl
    lhs: "subsumes(a, a)"
    rhs: "true"
  # transitivity: subsumption composes
  - name: subsumes_trans
    lhs: "and(subsumes(a, b), subsumes(b, c))"
    rhs: "subsumes(a, c)"
  # ground facts
  - name: train_subsumes_ingest
    lhs: "subsumes(train_model(), ingest())"
    rhs: "true"
  - name: train_subsumes_transform
    lhs: "subsumes(train_model(), transform())"
    rhs: "true"
  - name: train_subsumes_fine_tune
    lhs: "subsumes(train_model(), fine_tune())"
    rhs: "true"

A super important thing to note about this structured content model definition is that it's not some fixed model everyone needs to use. These models are intended to be partial models of the world that capture things that particular communities care about. There may be overlap in two communities' models, which the communities might define separately, and following Blaine's suggestion, one can link them via panproto's translation architecture, which we discuss below.

Composing building blocks into interfaces

An interface is built by merging building blocks together, which involves matching up the parts they share. If ThAtt says "there is a Holder and Content" and ThProhibitive also says "there is a Holder and Content," you want to end up with a single merged definition that has one Holder and one Content (not two copies of each), plus everything that each building block contributes individually. The engine does this merging automatically.20

So for example, a use prohibition could be the merge of ThAttThProhibitive, and ThUse, where Content in the attitude definitions is identified with Use in the content definition:

# compositions/use_prohibition.ncl
let T = import "panproto/theory.ncl" in
{
  id = "dev.attitudes.intent.use_prohibition",
  description = "Prohibition over a structured use.",
  compose = {
    result = "ThUseProhibition",
    bases = ["ThAtt", "ThProhibitive", "ThUse"],
    steps = [
      T.colimit_with_ops
        "ThAtt" "ThProhibitive"
        ["Holder", "Content", "Att"]
        ["holder", "content"],
      T.colimit
        "step_0" "ThUse"
        ["Content"],              # Content = Use
    ],
  },
}

After this composition, the resulting interface ThUseProhibition has everything from all its building blocks: HolderUse (which is Content), AttProhibitionActionMaterialPurposeActor, and all the extraction and subsumption operations. Panproto's engine guarantees that the result is the smallest interface consistent with all the inputs: nothing extra creeps in, and nothing shared is duplicated.

Records in ATProto

An implementation would fill in the abstract kinds with actual values. So for instance, within the ThUseProhibition interface, a specific prohibition type might fix Action = train_modelPurpose = any_purposeMaterial.scope = all. And a record would be a concrete entry asserting that a specific user stands in the prohibitive relation to a specific structured use.21

In ATProto, a record like this would live in a user’s repo.22

{
  "$type": "dev.attitudes.intent.use_prohibition",
  "use": {
    "action": "train_model",
    "material": { "scope": "all" },
    "purpose": "any_purpose"
  },
  "vocabulary": "dev.attitudes.vocab.content_use_v1",
  "createdAt": "2026-04-06T12:00:00Z"
}

Because the content is structured rather than flat, a consumer can actually do something with it. It reads this record, extracts the action (train_model), extracts the purpose (any_purpose), confirms this is a prohibition, and then determines its behavior by checking whether its own proposed action falls under the declared action. So if the consumer wants to ingest data for training, it checks whether train_model subsumes ingest, which evaluates to true by the equations in the use definition. The proposed action is covered by the prohibition.

But if the consumer instead wants to archive, it checks whether train_model subsumes archive. The definition contains no such equation, so the consumer's action is not covered. It then looks for other declarations and finds a separate permission for archive.

How "not covered" actually works

There is a subtlety here worth being explicit about. When I said "the definition contains no such equation" for subsumes(train_model(), archive()), I was relying on a design choice: the system treats its set of equations as complete.23 If a subsumption fact can't be derived from the declared equations (including reflexivity and transitivity), the system treats it as false.

But this is not the only possible choice. There are at least three reasonable options.

The first is a closed world with explicit default: basically, the equations are treated as complete, so the consumer knows definitively that train_model does not subsume archive. This is the simplest option and the one assumed throughout this post. Its advantage is computational clarity; its disadvantage is that the definition author must enumerate all subsumption relationships up front, and a missing equation is indistinguishable from a deliberate exclusion.

The second is an open world with gaps: here the absence of a derivation means the subsumption status is unknown, and the consumer distinguishes three states—subsumed, not subsumed, and unknown. Unknown status could trigger a fallback policy, like asking the user for clarification or applying a configurable default. This is more expressive but adds complexity for consumers.

The third is a hierarchy-closed world: the actions are organized into a strict hierarchy24 where every pair of actions has a definite relationship. This eliminates ambiguity while still allowing the hierarchy to be extended.

The sketch here assumes the first option. For user intents, where the set of actions is defined by a community vocabulary and is meant to be exhaustive within that vocabulary, this seems like a reasonable default. But systems operating across vocabulary boundaries—where one vocabulary might define actions that another does not recognize—may need the open-world or hierarchy-based approach.

Translations and what they lose

The connection to panproto's lens infrastructure is where the framework gets more interesting, I think, because it addresses a problem that simpler systems tend to sweep under the rug.

The basic idea is pretty simple: when you translate data from one schema to another, some structure might not survive the trip. If schema A has a field that schema B lacks, that field's data has to go somewhere. Panproto makes this explicit by pairing every translation with a complement, which is a structured record of exactly what was dropped and what had to be supplied.25

To see how this works, consider two interfaces: one for belief (ThDoxastic) and one for desire (ThBouletic). Both share the basic structure from ThAtt—holder, content, and attitude—but ThDoxastic adds a Belief kind with a doxastic_extract operation, while ThBouletic adds a Desire kind with a bouletic_extract operation. So a translation from ThDoxastic to ThBouletic maps the shared structure and produces a complement recording what changed: the belief parts were dropped (they have no counterpart in the target) and the desire parts were added (they have no counterpart in the source).

In the lens DSL:

# lenses/doxastic_to_bouletic.yaml
id: dev.attitudes.lens.doxastic_to_bouletic
description: >
  Translate from a belief-based interface to a desire-based
  interface. The complement captures the belief structure that
  the desire vocabulary cannot express.
source: dev.attitudes.doxastic
target: dev.attitudes.bouletic
steps:
  - drop_sort: Belief
  - drop_op: doxastic_extract
  - add_sort:
      name: Desire
      kind: structural
  - add_op:
      name: bouletic_extract
      src: Att
      tgt: Desire
      kind: structural

The complement of this lens, computed by panproto's complement_spec_at function, is:

{
  "kind": "Mixed",
  "forward_defaults": [
    {
      "element_name": "Desire",
      "element_kind": "sort",
      "description": "Default value needed for added sort 'Desire'."
    },
    {
      "element_name": "bouletic_extract",
      "element_kind": "op",
      "description": "Default value needed for added op 'bouletic_extract'."
    }
  ],
  "captured_data": [
    {
      "element_name": "Belief",
      "element_kind": "sort",
      "description": "Data for vertices of kind 'Belief' will be captured in the complement."
    },
    {
      "element_name": "doxastic_extract",
      "element_kind": "op",
      "description": "Edges of kind 'doxastic_extract' will be captured in the complement."
    }
  ],
  "summary": "Mixed: drops Belief and doxastic_extract (captured); adds Desire and bouletic_extract (defaults required)."
}

One important subtlety here: the complement's shape is not fixed in advance; it depends on the particular schema the translation is applied to.26 The same translation definition might, when applied to a schema with five belief-carrying fields, produce a complement with five entries; and when applied to a schema with two, produce a complement with two. Basically, the complement is computed, not declared, and it faithfully tracks the specific data at hand.

To see why this matters, consider a concrete scenario. Suppose a digital preservation community has built its vocabulary around permission and prohibition, because one thing archivists care about (alongside things like provenance) is recording the appropriate uses of the material they hold—what is permitted, what is prohibited, and under what conditions. A user in this community publishes a declaration like:

{
  "$type": "org.digipres.attitudes.deontic",
  "content": {
    "action": "archive",
    "material": { "scope": "public_posts" },
    "purpose": "preservation"
  },
  "permission": {
    "modality": "permitted",
    "conditions": [
      { "type": "non_commercial" },
      { "type": "attribution_required" }
    ],
    "basis": {
      "type": "community_policy",
      "url": "https://digipres.org/norms/archival-access"
    }
  },
  "createdAt": "2026-04-06T12:00:00Z"
}

This record carries information that only makes sense in a deontic vocabulary: the permission field has a modality (permitted/prohibited), a list of conditions under which the permission holds, and a basis pointing to the community policy that grounds it. That is the kind of structure a permission-oriented system cares about—not just whether the user has some attitude toward the action but exactly what is and is not allowed, and on what authority.

Now suppose a consumer—say a research lab planning a study—operates in an intention-based vocabulary, because what it cares about is recording the experiments it intends to run and the content it intends to use in those experiments. The same lens machinery applies, just with a different translation: a deontic_to_intentional lens that drops the deontic structure (the Permission kind, the deontic_extract operation) and adds an intention structure (an Intention kind and an intention_extract operation, with a means-end coherence equation27 linking the intention to the action). The shared structure—holder, content, the attitude relation—comes through fine. But the complement tells the consumer exactly what did and did not make it. The captured_data entries say: "the Permission kind and the deontic_extract operation were present in the source but have no counterpart here; their data has been set aside." And the forward_defaults entries say: "the Intention kind and the intention_extract operation exist in your vocabulary but had no counterpart in the source; you need to supply defaults for them." In practice, the lab's system might fill the intention default with a placeholder pointing to the planned experiment that triggered ingestion. And the deontic data—the modality, the conditions, the basis—rather than being silently discarded, is preserved in the complement so the lab can check before any experiment that its planned use actually falls within the permissions the user declared.

The intention vocabulary is only half of what the lab needs, though. Once an experiment has actually been run, the lab also wants to record what it now believes on the basis of the results: which datasets were used, which findings replicated, what those findings license inferentially, and what the lab is now committed to as a downstream consequence. That is a doxastic vocabulary, and the lens framework lets it coexist with the intention vocabulary without conflating them. The same machinery that translates archivist permissions into experiment plans can also translate experiment outcomes into structured beliefs the lab can publish, link, and revise—and it can do so over a chain of vocabularies, with the complements at each step making explicit what each translation cannot carry through.

This decomposition of scientific work into modular, shareable pieces that separate what was observed from what is claimed on the basis of those observations is closely related to the work on discourse graphs by and , which models a research lab's knowledge as a graph of evidence nodes and claim nodes that compose into larger arguments.28 The framework sketched here is doing something different at the protocol layer—formalizing the attitudes a vocabulary commits to and computing what survives translation between vocabularies—but the underlying motivation is the same: scientific and curatorial work is composed of modular pieces with explicit provenance, and a system that surfaces those pieces as first-class structured objects is more useful than one that flattens them.

The same mechanism applies to the more practically relevant case of translating between content vocabularies. If a user's declarations use the base content_use_v1 vocabulary with actions like archivetrain_modelreuse, and a consumer operates in the AIPREF vocabulary with finer-grained actions like pre_trainfine_tunerlhfrag_retrieveembedding, then the translation maps train_model to the AIPREF actions it subsumes and produces a complement containing rag_retrieve and embedding—actions the source vocabulary has no opinion about. The consumer sees the complement and knows it is operating in a gap for those specific actions.

Chaining translations

To complete the picture, consider a three-step chain: belief → desire → intention. The first translation drops belief structure and adds desire structure (as above). The second drops desire structure and adds intention structure, with an Intention kind, an intention_extract operation, and a coherence equation29 linking the intentional layer to the content layer.

# lenses/bouletic_to_intentional.yaml
id: dev.attitudes.lens.bouletic_to_intentional
description: >
  Translate from a desire-based interface to an intention-based
  interface. The complement captures the desire structure; the
  added means-end operations are structurally new.
source: dev.attitudes.bouletic
target: dev.attitudes.intentional
steps:
  - drop_sort: Desire
  - drop_op: bouletic_extract
  - add_sort:
      name: Intention
      kind: structural
  - add_op:
      name: intention_extract
      src: Att
      tgt: Intention
      kind: structural
  - add_op:
      name: committed_action
      src: Intention
      tgt: Action
      kind: structural
  - add_equation:
      name: means_end_coherence
      lhs: "committed_action(intention_extract(att))"
      rhs: "action(content(att))"

When we chain the two translations end to end,30 the resulting translation from belief → intention has a composite complement that accumulates everything lost along the way: {Belief, doxastic_extract, Desire, bouletic_extract}. So translating from a belief-based vocabulary all the way to an intention-based vocabulary loses both the belief and the desire structure, even though the intermediate desire-based vocabulary was used as a stepping stone. But nothing is silently dropped because the composed complement is the product of the individual complements, accumulated through each step.31 When a user expresses their intents in one vocabulary and a consumer operates in another, and the translation between them passes through any number of intermediate vocabularies, the complement at the end of the chain tells the consumer what it can't see.

Nested attitudes

The examples so far have all been flat: a user stands in some relationship to some content. But ATProto is a decentralized network, and in practice intent signals do not come from a single authoritative source. They come from a variety of sources: a user's PDS, from labels applied by third-party labelers, from Creative Commons licenses on external websites, from research consent forms, and so on. A consumer that encounters these signals needs to represent not just what the intent is but who thinks that's the intent and on what basis. That might suggest we need a nested structure to capture things like a belief about an intent or a belief about a belief about an intent.

Concretely, consider a labeler on the ATProto network that aggregates consent signals. It encounters a user who has two relevant signals: an ATProto declaration in their repo saying "I prohibit commercial training," and a CC-BY-NC license on their personal blog. With flat attitudes, the labeler can record the prohibition from the ATProto declaration directly. But what does it do with the CC-BY-NC license? That license is not an ATProto record; the labeler is interpreting it as evidence that the user permits non-commercial use. If the labeler just writes a flat permission record, it looks identical to something the user published themselves, and the provenance is lost.

With nested attitudes, the labeler can represent exactly what it knows. The ATProto declaration is a first-order intent that the labeler can relay directly. But the CC-BY-NC interpretation is a second-order belief: "the labeler believes, on the basis of a CC-BY-NC license at URL X, that the user permits non-commercial archiving." The natural way to represent this in ATProto is for the labeler to write two records in its own repo. The first is a plain permission record representing the labeler's interpretation of what the user permits:

{
  "$type": "dev.attitudes.intent.use_permission",
  "holder": "did:plc:user123",
  "use": {
    "action": "archive",
    "material": { "scope": "public_posts" },
    "purpose": "non_commercial"
  },
  "createdAt": "2026-04-06T12:00:00Z"
}

The second is a belief record whose subject is a com.atproto.repo.strongRef pointing to the permission record by AT-URI and CID, pinning the exact version:

{
  "$type": "dev.attitudes.intent.belief",
  "holder": "did:plc:labeler456",
  "basis": {
    "type": "external_license",
    "url": "https://user.example.com/license",
    "license_type": "CC-BY-NC-4.0"
  },
  "subject": {
    "uri": "at://did:plc:labeler456/dev.attitudes.intent.use_permission/3l5fk7aqcco2m",
    "cid": "bafyreidfcm4u3vnuph5ltwdpssiz3a4xfbm2otjrdisftwnbfmnxd6lsxm"
  },
  "createdAt": "2026-04-06T12:00:00Z"
}

This is just standard ATProto record referencing: the subject field uses a strongRef, which is how records like app.bsky.feed.like point to the posts they target. The CID ensures the belief is pinned to a specific version of the permission record—if the labeler later revises its interpretation, the old belief still points to the old version. A consumer reading the belief record resolves the subject to get the permission record, and the combined structure tells the full story: the labeler inferred, on the basis of a CC-BY-NC license at a specific URL, that the user permits non-commercial archiving.

Because the permission record lives in the labeler's repo, not the user's, the provenance is structurally explicit. If a second labeler disagrees—say it interprets the same CC license as not extending to ATProto content—that disagreement shows up as two distinct belief records from different DIDs, each pointing (via strongRef) to its own permission record. A consumer can see who asserted what. With flat records, you would just have two contradictory permission records with no obvious way to tell them apart.

This nesting works because ATProto records can reference other records, and nothing about the Lexicon schema system prevents a record type's subject from pointing to another record of the same family. The schema for dev.attitudes.intent.belief just declares that subject is a com.atproto.repo.strongRef—the protocol handles the rest.

Panproto's role in this case is a bit different than in the previous cases: when a consumer resolves the reference chain and assembles the full tree of attitudes, panproto's type system can represent that tree32 and—more importantly—the lens infrastructure can translate it, computing complements correctly regardless of how deep the nesting goes.

The same structure extends naturally to delegation and trust chains. If platform A subscribes to labeler B's feed and uses B's beliefs about user intents to make its own policy decisions, A can write a record whose subject is a strongRef to B's belief record—"A accepts B's belief that the user prohibits training"—and the chain of references is traversable. The complement of the translation from A's trust vocabulary to B's belief vocabulary tells A exactly what structural commitments B made that A does not share. Without nesting, it is not clear how you would represent this chain or ask where a particular intent claim came from.

Practical considerations

There are a number of practical challenges that this system might face.

Conflict resolution

The examples above have been carefully chosen to avoid a problem that any actual deployment would face immediately: conflicting declarations. Suppose a user publishes a prohibition on train_model for any_purpose and a separate permission on fine_tune for academic purpose. Since train_model subsumes fine_tune, the prohibition covers fine-tuning. But the user has also explicitly permitted fine-tuning for academic purposes. So what should a consumer do?

This is a well-studied problem.33 And the standard approaches map onto concrete design choices here.

The first approach is specificity-based override: a more specific declaration takes precedence over a more general one. In the example, the permission on fine_tune for academic purpose is more specific than the prohibition on train_model for any_purpose along two dimensions (action and purpose), so it wins. The basic idea is that you derive a "more specific than" ordering from the subsumption order on content: declaration A is more specific than B if A's action is subsumed by B's action and A's purpose is subsumed by B's purpose. The more specific declaration wins. This is the approach taken by most access-control systems, and it seems like a reasonable default.

The second approach is temporal precedence: a later declaration overrides an earlier one on the same content. This is simple (declarations already carry createdAt timestamps), but it has the disadvantage that a user who publishes a broad prohibition and then a narrow permission may not realize the narrow permission has punched a hole in the broad prohibition.

The third approach is explicit conflict marking: the system detects conflicts and surfaces them to the user for resolution. This is the most conservative option and may be appropriate for an initial deployment where the consequences of getting it wrong are high.

In practice, a combination of the first and third seems most appropriate: use specificity-based override as the default, but flag conflicts for user review when the specificity order is ambiguous, that is, when neither declaration is strictly more specific than the other.

Default policy

A related but distinct problem is what a consumer should do when the user has expressed no attitude about a particular use. In practice, this is a super common case: most users will declare a handful of preferences, and consumers will encounter action/purpose/actor combinations the user never contemplated.

The sketch as described has no built-in default. Basically, the consumer checks for applicable declarations, finds none, and has no basis for action. So this is a gap that needs to be filled.

The natural solution is to allow a user to publish a catch-all declaration with wildcarded content—something like a prohibition on reuse for any_purpose with material.scope = all—which would serve as the default for any use not covered by a more specific declaration. Combined with specificity-based conflict resolution, this gives you a clean two-level system: the catch-all sets the default, and specific declarations override it. A user who wants default-deny publishes a broad prohibition and punches specific holes with permissions. A user who wants default-allow publishes a broad permission and blocks specific uses with prohibitions.

For users who publish no declarations at all, the system needs a platform-level default. This is a governance decision rather than a formal one, but the framework could make it expressible as an attitude declaration so that the default policy is transparent and auditable rather than buried in application code.

Action hierarchies

The subsumption relation on actions is currently a partial order specified by explicit ground equations plus reflexivity and transitivity. This approach seems workable, but it has a practical limitation: for any pair of actions not connected by a chain of equations, subsumption is simply false (under the closed-world assumption). And as vocabularies grow, the number of equations needed to specify the order can in principle grow quadratically — though in practice you only declare the covering relations and let reflexivity and transitivity handle the rest.

A more structured alternative is to organize actions into a strict hierarchy34 with a top element (reuse, representing any use whatsoever) and atoms representing the most specific actions. In a hierarchy like this, every pair of actions has a well-defined "least common ancestor," and subsumption is just the hierarchy order. This eliminates the need for explicit ground equations (the hierarchy determines all subsumption facts) and makes subsumption decidable for every pair.

The same hierarchy can be applied to the Purpose kind. This would also provide a natural framework for the specificity-based conflict resolution discussed above: "more specific" simply means "lower in the hierarchy," and the comparison is always decidable.

The sketch here does not impose hierarchy on action or purpose kinds, and there may be good reasons not to. Different communities may organize their action spaces in ways that are not hierarchy-compatible—two action types may overlap without either subsuming the other, and imposing a strict hierarchy would rule out such partial orders. But for the common case where the action space is tree-shaped, a hierarchy would simplify both the definitions and the consumer-side reasoning, and it seems worth exploring.

Feasibility

A question that has not been addressed so far is whether something like this would actually be tractable at ATProto scale, where a consumer might need to evaluate millions of user declarations in real time (as an automated labeler or feed generator would).

The core operations are: (i) checking whether a proposed action is subsumed by a declared action, which reduces to reasoning over a finite set of equations;35 (ii) computing the complement of a translation at a given schema, which is a compile-time operation on the definition structure; and (iii) evaluating a translation on a concrete record, which is basically a linear-time traversal of the record's fields.

For (i), each subsumption query reduces to reachability over the directed graph defined by the ground equations, which can be answered in time linear in the number of edges (or made constant-time per query by precomputing the transitive closure once per vocabulary). For (ii), complement computation is performed once per (translation, schema) pair, not once per record, so its cost is amortized across all records using that schema. For (iii), record translation is dominated by the size of the record, which would likely be small in general (a handful of fields).

The more relevant cost in practice is finding applicable declarations for a given proposed use, which requires searching a user's repo for declarations whose content matches the proposed action, material, purpose, and actor. In ATProto, records in a user's repo are stored in a Merkle search tree indexed by collection and record key, so a consumer querying a specific user's declarations can list the relevant collections (e.g. dev.attitudes.intent.use_prohibitiondev.attitudes.intent.use_permission) and retrieve all records in each. The more challenging case is aggregating across many users—for instance, a labeler that wants to know which users have prohibited training. This requires either an index over the network or a feed-generator-style architecture that subscribes to the firehose and maintains its own index. But this kind of framework would not introduce new costs here beyond what any structured-data system on ATProto would face.

Why now

The practical context for all of this is that the ATProto ecosystem is actively working through the problem of user consent for data reuse, and the solutions being proposed range from simple boolean toggles (the base user intents proposal) to community-defined lexicon extensions to Blaine's vision of vocabularies as living data. What I want to suggest is that much of the formalism needed to make Blaine's vision work may already exist, that it is grounded in a well-established theory of how agents relate to structured content, and that panproto's theory and lens DSLs provide one possible way to implement it.

All of the examples above use the actual DSL syntax of panproto: the building-block definitions compile into panproto's internal representation, the compositions produce interfaces by merging, the translations produce chains with typed complements, and the records are structured entries that live in ATProto repos. And because the whole thing is defined as an external package of data files that the engine interprets, none of it requires changes to panproto or to ATProto.

So insofar as the problem of user intents on ATProto is a problem of representing what agents believe, desire, intend, permit, and prohibit about structured content—and of translating between different communities' ways of carving up that space with an explicit accounting of what is lost—it seems like the same kind of problem that theories of attitudes have been studying for decades. The small, recurring set of attitudinal building blocks I am sketching here is reflected in the semantics of how people actually talk about what they and others believe, want, intend, and permit.

With that said, I don't think we should overindex on what natural languages do. Instead, I think we want to take it as a useful lower bound on what the system should be able to represent, because it tells us that the building blocks are at least rich enough to capture the distinctions that humans actually make.