16 Protolenses: Reusable Lens Families
Suppose you’re managing a platform where hundreds of user-defined schemas exist, and your legal team mandates a naming convention change: createdAt becomes created_at, updatedAt becomes updated_at. You could hand-write the same lens twice, three times, a hundred times. Or you could write it once and reuse it across all compatible schemas. That’s what a protolens does.
A protolens is a transformation pattern that works across schemas. It’s a pair \((P, F)\): a precondition \(P\) that describes which schemas are applicable, and a lens generator \(F\) that produces a concrete lens for any schema satisfying \(P\). A renameVertex("author", "creator") protolens has precondition “schema must contain a vertex named author” and generates a lens for each schema meeting that requirement.
The power comes from schema-dependence. The complement type depends on the schema: when you remove metadata, the complement stores the removed data. If metadata is a string, the complement stores a string. If metadata is a structured object, the complement stores that object. The same protolens produces different complement shapes for different schemas—this is why protolenses are more than glorified templates.
16.1 Why manual lenses become tedious
Picture a real platform scenario. You have user-defined schemas A, B, C, and more. Each has a metadata block with createdAt and updatedAt fields. Policy changes. You need to normalize to snake_case.
With the combinator API from Section 7.4, you write:
// For schema A
const lensA = pipeline(
renameField("metadata.createdAt", "metadata.created_at"),
renameField("metadata.updatedAt", "metadata.updated_at"),
);
// For schema B (identical logic, different schema)
const lensB = pipeline(
renameField("metadata.createdAt", "metadata.created_at"),
renameField("metadata.updatedAt", "metadata.updated_at"),
);
// For schema C, D, E, F, ...Repetition. The transformation pattern stays the same while the target schemas differ. A protolens captures the pattern once and applies it to any schema that matches the precondition.
16.2 What a protolens is
A protolens has two parts:
- Precondition \(P\): a test on the schema. “Does this schema have fields
metadata.createdAtandmetadata.updatedAt?” - Lens generator: for any schema \(S\) where \(P(S)\) holds, produce a lens \(\ell_S : S \rightleftarrows F(S)\), where \(F(S)\) is the transformed schema.
The schema \(S\) varies. The transformation pattern stays fixed. The output type depends on the input: different schemas produce different lenses with different complement structures.1
The lens laws from Chapter 7 (GetPut and PutGet) hold for every instantiated lens \(\ell_S\). These aren’t re-derived; they follow by construction from the elementary protolens constructors.
16.3 Schema transforms
The function \(F\) above—which modifies a schema—is called a schema transform. panproto defines a library of elementary schema transforms, each a TheoryTransform variant:
| Schema transform | What it does | Example |
|---|---|---|
RenameVertex(old, new) |
Rename a vertex ID | author becomes creator |
RenameEdge(old, new) |
Rename an edge kind | hasAuthor becomes hasCreator |
AddVertex(spec) |
Add a vertex with given kind | Add version: int |
RemoveVertex(id) |
Remove a vertex and its edges | Drop internalId |
AddEdge(spec) |
Add an edge between existing vertices | Add author -> org edge |
RemoveEdge(id) |
Remove an edge | Drop replyTo edge |
WrapVertex(id, wrapper) |
Nest a vertex inside a new container | address becomes address.street inside address |
HoistVertex(id) |
Move a nested vertex up one level | profile.name becomes name |
CoerceType(id, target) |
Change a vertex’s data type | age: string becomes age: int |
MergeVertices(ids, into) |
Merge multiple vertices | firstName + lastName into fullName |
SplitVertex(id, into) |
Split a vertex into multiple | fullName into firstName + lastName |
Each transform has a precondition. RenameVertex("author", "creator") requires author to exist. RemoveVertex("internalId") requires the vertex to exist. WrapVertex("address", "location") requires address to exist and location to not exist yet.
Transforms compose into pipelines. A sequence \(F_1, F_2, \ldots, F_n\) applies step by step, and the precondition for the composite checks each step against the schema as it exists after previous steps: \(P_1(S) \wedge P_2(F_1(S)) \wedge \ldots \wedge P_n(F_{n-1}(\ldots F_1(S)))\).
16.4 Instantiation: materializing a lens
You have a protolens. You have a schema. Instantiation checks the precondition and produces a concrete lens if it passes.
import { Panproto, ProtolensChainHandle } from "@panproto/core";
// Define a protolens chain
const chain = ProtolensChainHandle.autoGenerate(oldSchema, newSchema);
// Instantiate against a specific schema
const lens = chain.instantiate(concreteSchema);
// Use the lens as in @sec-lenses
const { view, complement } = lens.get(sourceData);
const restored = lens.put(modifiedView, complement);If concreteSchema doesn’t satisfy the precondition, instantiation fails with details:
error: protolens precondition not satisfied
step 2: RemoveVertex("internalId")
requires: vertex "internalId" exists in schema
but: schema "my-schema-v3" has no vertex "internalId"
hint: this schema may have already undergone this transformation
The same chain instantiates against many schemas:
const schemas = await Panproto.listSchemas();
for (const schema of schemas) {
try {
const lens = chain.instantiate(schema);
console.log(`${schema.name}: lens ready`);
} catch (e) {
console.log(`${schema.name}: not applicable: ${e.message}`);
}
}If a schema underwent the transformation already (e.g., internalId was already removed in a prior version), instantiation fails. This is intentional: applying the same protolens twice violates the precondition. Use check_applicability to filter schemas before instantiation.
Instantiation fails. The precondition checks whether the schema has the elements the protolens expects. If a prior transformation already removed internalId, the RemoveVertex("internalId") step’s precondition is not satisfied, and instantiation reports which step failed and why. Use check_applicability to filter schemas before instantiation.
16.5 Commutativity: applying protolenses before or after migration
Here’s a useful property: applying a protolens and then migrating gives the same result as migrating first and then applying the protolens.2
Suppose you have schemas v1 and v2 with a migration between them. You also have a protolens that renames createdAt to created_at. You can:
- Migrate data from v1 to v2, then apply the rename protolens to v2 data, or
- Apply the rename protolens to v1 data, then migrate the renamed data from v1 to v2
Both paths produce the same output. In a migration pipeline \(S_1 \to S_2 \to S_3\), you can apply a schema-level protolens at any point and get consistent results. Without this property, the order would matter, and composing protolenses with migrations would require careful sequencing.
Commutativity holds for all 11 elementary constructors and their compositions, provided the migration preserves the protolens’s precondition. If the migration removes a vertex the protolens needs, both sides of the equation fail (the protolens isn’t applicable), and the property holds vacuously.
Yes, for all 11 elementary constructors and their compositions, provided the migration preserves the protolens’s precondition. If the migration removes a vertex the protolens requires, the protolens is not applicable post-migration, and both sides fail—the commutativity condition holds vacuously.
16.6 The 11 elementary protolens constructors
Each schema transform from Table 16.1 has a corresponding protolens constructor:
16.6.1 1. renameVertex
const pl = Protolens.renameVertex("author", "creator");
// precondition: schema has vertex "author"
// effect: renames to "creator", all edges updated
// complement: empty (bijective)16.6.2 2. renameEdge
const pl = Protolens.renameEdge("hasAuthor", "hasCreator");
// precondition: schema has edge kind "hasAuthor"
// effect: renames edge kind
// complement: empty (bijective)16.6.3 3. addVertex
const pl = Protolens.addVertex("version", { kind: "int", default: 1 });
// precondition: schema does NOT have vertex "version"
// effect: adds vertex with default value
// complement: stores added value (for removal on put)16.6.4 4. removeVertex
const pl = Protolens.removeVertex("internalId");
// precondition: schema has vertex "internalId"
// effect: removes vertex and its edges
// complement: stores removed vertex data16.6.5 5. addEdge
const pl = Protolens.addEdge("worksAt", { from: "author", to: "org" });
// precondition: both endpoints exist, edge does not
// effect: adds edge (data must be provided or defaulted)
// complement: stores added edge data16.6.6 6. removeEdge
const pl = Protolens.removeEdge("replyTo");
// precondition: schema has edge "replyTo"
// effect: removes edge
// complement: stores removed edge data16.6.7 7. wrapVertex
const pl = Protolens.wrapVertex("street", { wrapper: "address" });
// precondition: "street" exists, "address" does not
// effect: creates "address" container, nests "street" inside
// complement: stores wrapper structure16.6.8 8. hoistVertex
const pl = Protolens.hoistVertex("address.street");
// precondition: "address.street" exists
// effect: moves "street" up to parent level
// complement: stores original nesting16.6.9 9. coerceType
const pl = Protolens.coerceType("age", { from: "string", to: "int" });
// precondition: "age" exists with kind "string"
// effect: converts value using registered coercion
// complement: empty if coercion is bijective, stores original otherwise16.6.10 10. mergeVertices
const pl = Protolens.mergeVertices(["firstName", "lastName"], {
into: "fullName",
merge: (first, last) => `${first} ${last}`,
split: (full) => { const [f, ...l] = full.split(" "); return [f, l.join(" ")]; },
});
// precondition: all source vertices exist
// effect: merges into single vertex
// complement: stores split function result for round-trip16.6.11 11. splitVertex
const pl = Protolens.splitVertex("fullName", {
into: ["firstName", "lastName"],
split: (full) => { const [f, ...l] = full.split(" "); return [f, l.join(" ")]; },
merge: (first, last) => `${first} ${last}`,
});
// precondition: "fullName" exists
// effect: splits into multiple vertices
// complement: stores merge function result for round-trip16.7 Schema-dependent complement types
In Section 7.2 we saw that complements are data structures. For protolenses, the complement structure depends on the schema. A removeVertex("internalId") protolens produces different complements:
- Schema A has
internalId: string→ complement stores a string - Schema B has
internalId: { hash: bytes, counter: int }→ complement stores an object
The complement type is a function from schemas to types. In panproto’s implementation, complements are dynamically typed (CBOR-serialized), so this dependence is handled at runtime. The complement structure—which fields it contains, how they nest—varies with the schema.
This is what distinguishes protolenses from templates. A template would produce the same complement every time. A protolens produces a complement whose structure matches the schema it was instantiated against.
16.8 Trying it from the CLI
The prot protolens command shows the protolens chain between two schemas:
$ prot protolens old.json new.json
Protolens chain (3 steps):
1. RenameVertex("userName" -> "handle")
2. RemoveVertex("internalId")
complement: stores { internalId: string }
3. AddVertex("version", default: 1)
Precondition:
- vertex "userName" must exist
- vertex "internalId" must exist
- vertex "version" must NOT exist
Applicable to: any schema satisfying the preconditionInstantiate against a specific schema:
$ prot protolens old.json new.json --instantiate my-schema.json
Instantiated lens for "my-schema-v2":
get: my-schema-v2 -> my-schema-v2' (3 fields affected)
put: my-schema-v2' x complement -> my-schema-v2
complement size: ~48 bytes per recordConvert data through the instantiated lens:
$ prot convert --protolens old.json new.json --schema my-schema.json data.json16.9 Using protolenses in TypeScript
The full workflow:
import { Panproto, ProtolensChainHandle, LensHandle } from "@panproto/core";
// Step 1: Generate a protolens chain from two reference schemas
const chain = ProtolensChainHandle.autoGenerate(oldSchema, newSchema);
// Step 2: Inspect the chain
console.log(chain.steps);
// [
// { type: "RenameVertex", from: "userName", to: "handle" },
// { type: "RemoveVertex", id: "internalId" },
// { type: "AddVertex", id: "version", default: 1 },
// ]
// Step 3: Instantiate against any compatible schema
const lens: LensHandle = chain.instantiate(mySchema);
// Step 4: Use the lens
const { view, complement } = lens.get(sourceData);
// ... modify view ...
const restored = lens.put(modifiedView, complement);
// Step 5: Convert data directly
const converted = Panproto.convert(sourceData, { protolens: chain, schema: mySchema });16.10 Using protolenses in Python
The equivalent Python workflow:
from panproto import Panproto, ProtolensChainHandle, LensHandle
# Step 1: Generate a protolens chain from two reference schemas
chain = ProtolensChainHandle.auto_generate(old_schema, new_schema)
# Step 2: Inspect the chain
print(chain.steps)
# [
# RenameVertex(from_="userName", to="handle"),
# RemoveVertex(id="internalId"),
# AddVertex(id="version", default=1),
# ]
# Step 3: Instantiate against any compatible schema
lens: LensHandle = chain.instantiate(my_schema)
# Step 4: Use the lens
view, complement = lens.get(source_data)
# ... modify view ...
restored = lens.put(modified_view, complement)
# Step 5: Convert data directly
converted = Panproto.convert(source_data, protolens=chain, schema=my_schema)16.11 Serialization for reuse
Protolens chains serialize to JSON losslessly:
const chain = ProtolensChainHandle.autoGenerate(oldSchema, newSchema);
// Serialize to JSON
const json = chain.toJson();
fs.writeFileSync("policy.json", JSON.stringify(json));
// Deserialize from JSON
const restored = ProtolensChainHandle.fromJson(json);
const lens = restored.instantiate(someSchema);chain = ProtolensChainHandle.auto_generate(old_schema, new_schema)
# Serialize
policy = chain.to_json()
Path("policy.json").write_text(json.dumps(policy))
# Deserialize
restored = ProtolensChainHandle.from_json(policy)
lens = restored.instantiate(some_schema)Three reasons this matters:
- Distributing policies. A platform team defines a protolens chain and publishes it as JSON. Downstream teams apply it to their own schemas without regenerating.
- Version control. Protolens chains committed alongside schema versions provide an auditable migration history.
- Deferred application. A chain created today can apply to schemas that don’t yet exist. When a new schema arrives, deserialize and instantiate.
From the CLI:
schema lens old.json new.json --protocol atproto --chain > policy.json16.12 Checking which schemas are compatible
The check_applicability method returns failure reasons instead of a boolean:
const reasons = chain.checkApplicability(schema);
if (reasons.length > 0) {
console.log("Not applicable:");
for (const r of reasons) {
console.log(` step ${r.step}: ${r.reason}`);
}
} else {
const lens = chain.instantiate(schema);
}reasons = chain.check_applicability(schema)
if reasons:
for r in reasons:
print(f" step {r.step}: {r.reason}")
else:
lens = chain.instantiate(schema)applicable_to is a boolean. check_applicability tells you why not: which step failed, what’s missing, and what the schema would need. Use this before attempting instantiation on a batch of schemas.
From the CLI:
schema lens-verify --check-fleet ./schemas/16.13 Applying a protolens to many schemas
apply_to_fleet runs a protolens chain across multiple schemas, partitioning results into successes and failures:
import { applyToFleet } from "@panproto/core";
const result = applyToFleet(chain, schemas, protocol);
console.log(`Applied to ${result.applied.length} schemas`);
for (const entry of result.applied) {
console.log(` ${entry.schema.name}: lens ready`);
}
console.log(`Skipped ${result.skipped.length} schemas`);
for (const entry of result.skipped) {
console.log(` ${entry.schema.name}: ${entry.reasons.join(", ")}`);
}from panproto import apply_to_fleet
result = apply_to_fleet(chain, schemas, protocol)
print(f"Applied to {len(result.applied)} schemas")
for entry in result.applied:
print(f" {entry.schema.name}: lens ready")
print(f"Skipped {len(result.skipped)} schemas")
for entry in result.skipped:
print(f" {entry.schema.name}: {', '.join(entry.reasons)}")From the CLI, --dry-run produces a report without applying changes:
schema lens-fleet policy.json ./schemas/ --protocol atproto --dry-runSample output:
Fleet migration report:
Applied (12 schemas):
users.json: 3 steps, complement: Empty
posts.json: 3 steps, complement: DataCaptured(1 field)
comments.json: 3 steps, complement: Empty
...
Skipped (2 schemas):
legacy-events.json: step 1 requires vertex "metadata" (not found)
internal-logs.json: step 2 requires edge kind "hasAuthor" (not found)
Formally, a protolens is a dependent function \(\Pi(S : \mathsf{Schema} \mid P(S)). \mathsf{Lens}(S, F(S))\). \(S\) ranges over schemas satisfying \(P\), and \(F\) transforms the schema. See Appendix A.↩︎
This is a naturality condition in the categorical sense. See Appendix A.↩︎