10 Playbooks: One Workflow, Different Pressures
These situations differ, but they should not begin from different design philosophies. Each starts with the same workflow and then emphasizes the steps where its pressures are unusual.
Choose a playbook by asking two questions: where does the truth about correctness live, and what kind of life does the artifact have? A published format points to external conformance; foreign type definitions to translation; an invented vocabulary to language design; deferred expensive work to configuration; a live resource to fact/process separation; and third-party implementations to an extension contract. A real package often needs several answers. The workflow below stays shared so those parts can meet without inventing incompatible design philosophies.
10.2 Playbook 1: Modeling an existing specification
Pressure. Contract authority is external, versions change, and unknown content may matter.
Opening. You have a published format—perhaps STAC, GeoJSON, OpenAPI, or a style document—and users must construct, edit, and emit conforming documents. Collect the specification, machine-readable schema, official validator, reference implementation, and representative public examples before choosing classes. The first design artifact is a small vertical slice, not a transcription of every schema definition.
- Include the specification, official validator, reference implementation, and representative documents in the consumer inventory.
- Sketch a vertical slice rather than transcribing the schema: one typed node, one open node, one reference edge, and one round trip.
- Record presence separately from nullability and defaults. External formats commonly distinguish omitted,
null, empty, and defaulted values. - Put document-wide references and uniqueness at the aggregate root; retain the external validator as conformance oracle.
- Design operations users need inside R. Those operations, not schema field count, determine which nodes deserve semantic classes.
- Extract recipes from recurring shapes or an owned machine-readable specification. Generated native classes are legitimate when source and authority are explicit.
- State the unknown-key and version policy per node. Preserve extensions only if serializers and transformations can keep them.
- Test conformance and the named round-trip relation. Byte identity, canonical equality, and domain equality are different laws.
stacbuildr shows native constructors plus external conformance. S7schema shows detailed entries remaining under external-schema authority.
10.2.1 A parser/serializer slice
The slice below keeps wire spelling at the boundary, injects the constant type field at serialization, remembers whether an engine default was actually supplied, and preserves unknown keys. That presence bit is necessary: without it, parsing an explicit minzoom = 0 and parsing an omitted minzoom would collapse two wire states that a later specification version may treat differently.
to_wire <- S7::new_generic("to_wire", "x")
vector_source <- S7::new_class(
"vector_source",
properties = list(
url = S7::class_character,
min_zoom = S7::new_property(S7::class_numeric, default = 0),
min_zoom_supplied = S7::new_property(S7::class_logical, default = FALSE),
extensions = S7::new_property(S7::class_list, default = list())
)
)
source_from_wire <- function(x) {
if (!is.list(x) || is.null(x[["url"]])) {
cli::cli_abort("A vector source must contain {.field url}.")
}
known <- c("type", "url", "minzoom")
supplied <- "minzoom" %in% names(x)
vector_source(
url = x[["url"]],
min_zoom = if (supplied) x[["minzoom"]] else 0,
min_zoom_supplied = supplied,
extensions = x[setdiff(names(x), known)]
)
}
S7::method(to_wire, vector_source) <- function(x) {
out <- c(list(type = "vector", url = x@url), x@extensions)
if (x@min_zoom_supplied) {
out[["minzoom"]] <- x@min_zoom
}
out
}The law is not byte identity: key order may change. It is equality of known meaning plus preservation of untouched extensions and suppliedness. The short trap is normalizing before recording evidence. Its consequence is a serializer that cannot distinguish an omitted default from an explicit value, so an apparently harmless round trip silently rewrites user intent.
10.3 Playbook 2: Translating foreign type definitions
Pressure. A foreign type system expresses distinctions R may encode differently.
Opening. The source of truth is a TypeScript declaration, Rust enum, Go struct, C header, or protobuf schema. This is not yet a domain model. Begin with a ledger that records what crosses the boundary and what is lost, then separate generated transport shapes from hand-designed semantic concepts. Direction determines ownership. If R only decodes a foreign artifact, the foreign validator and parser own conformance while the R model may be a one-way semantic projection. If R also emits artifacts, the encoder owns omission, defaults, and unknown-field preservation, and conformance must be tested in that direction as well. A bidirectional claim therefore requires two boundary contracts, not one vaguely “equivalent” class graph.
- Inventory foreign meanings and both sides’ consumers.
- Sketch tagged unions, newtypes, optional fields, and extension records.
- Create a translation ledger for required, absent,
null, defaulted, and unknown values. Do not collapseundefined, explicitnull, and an absent key without policy. - Separate transport checks from domain invariants and host-context rules.
- Import behavioral laws, not only shapes. Ownership, read-only declarations, or capabilities may need operations rather than properties.
- Extract translation recipes only after a construct recurs.
- Name direction and loss: decoding, encoding, adapting, or compiling are not automatically faithful conversions.
- Test conformance in each direction and use a named equality relation for bidirectional claims.
A concrete starting ledger prevents each class from improvising a different translation:
| Foreign construct | R/S7 representation | Crossing policy |
|---|---|---|
field?: T |
presence record plus a T slot |
preserve absent separately from present |
field: T \| null |
union with NULL |
explicit null is a supplied value |
| caller/defaulted field | value plus provenance state | record who owns materialization |
tagged union / oneof |
abstract parent plus concrete variants | tag becomes variant identity |
| string-literal union | validated enum property | reject or version-gate unknown literals |
| Rust newtype | reused property or semantic class | retain unit/meaning, not only storage |
| unknown protobuf fields | inert extension bag | preserve, but do not interpret |
readonly declaration |
value or read-only snapshot API | import the law, not its syntax |
A tagged union may become concrete variants under an abstract dispatch parent, but the parent is not sealed and does not prove coverage. Maintain method-coverage tests when a known family must be handled.
The trap is shape-only translation. It produces classes that resemble the foreign declarations while dropping ownership, equality, readonly, and presence laws. The consequence appears later as incompatible round trips and defensive checks spread across every consumer.
10.4 Playbook 3: Building a language or semantic model
Pressure. The package owns the vocabulary, so premature abstraction is easy.
Opening. You are inventing an AST, query vocabulary, compiler IR, or embedded language. Write target artifacts by hand first. Stay with opaque payloads while serialization is the only operation; add combinators when construction is painful; introduce typed nodes only when analysis or multi-target behavior pays for them.
Target-language fluency comes before class design: until the package can state which target forms are valid, idiomatic, and meaning-preserving, an AST merely formalizes guesses. Make one early representation choice explicit as well: will users construct the language through dedicated constructors, or will the package capture and interpret quoted R expressions? Constructor APIs are more verbose but keep host-language evaluation out of the grammar; quoted R can feel natural but requires a precise policy for names, calls, environments, and unsupported syntax.
- Inventory actual consumers: rendering, analysis, optimization, comparison, or execution.
- Sketch the smallest meaningful node family and write target artifacts by hand.
- Give nodes precise local contracts; leave embedded languages opaque when no operation interprets them.
- Place tree-wide name resolution and reference laws at the root.
- Define passes as operations. Analysis as well as rendering is strong evidence that typed nodes carry useful meaning.
- Extract smart constructors and node recipes after usage becomes repetitive.
- Distinguish faithful conversion, policy-bearing adaptation or compilation, and an IR earned by several sources, targets, or analyses.
- Test rendering laws, method coverage, normalization, and explicit refusal of unsupported syntax.
sqlr demonstrates a middle representation earned by several consumers.
10.4.1 A small AST progression
Start with one leaf and one composition node, then justify the hierarchy with two independent passes:
filter_expr <- S7::new_class("filter_expr", abstract = TRUE)
comparison <- S7::new_class(
"comparison",
parent = filter_expr,
properties = list(
op = S7::new_property(
S7::class_character,
validator = function(value) {
if (length(value) != 1L || !value %in% c("==", "!=", "<", ">")) {
"must be one of ==, !=, <, >"
}
}
),
field = S7::class_character,
value = S7::class_any
)
)
all_of <- S7::new_class(
"all_of",
parent = filter_expr,
properties = list(
expressions = S7::new_property(
S7::class_list,
validator = function(value) {
inherits_expr <- vapply(
value,
S7::S7_inherits,
logical(1),
class = filter_expr
)
if (!all(inherits_expr)) {
"every element must be a filter_expr"
}
}
)
)
)
render_filter <- S7::new_generic("render_filter", "x")
fields_used <- S7::new_generic("fields_used", "x")
S7::method(render_filter, comparison) <- function(x) {
list(op = x@op, field = x@field, value = x@value)
}
S7::method(render_filter, all_of) <- function(x) {
list(all = lapply(x@expressions, render_filter))
}
S7::method(fields_used, comparison) <- function(x) x@field
S7::method(fields_used, all_of) <- function(x) {
unique(unlist(lapply(x@expressions, fields_used)))
}render_filter() alone could have emitted lists from constructor functions. fields_used() is the second consumer that makes node identity useful. The trap is representation deeper than analysis. Its consequence is a large class family whose only behavior is reconstructing the list it replaced.
10.5 Playbook 4: Configuring expensive computation
Pressure. Some mistakes are cheap during setup but expensive during execution; other rules need data not yet available.
Opening. A configuration exists now, but the data, host, or compute budget needed to finish checking it arrives later. Split specification from preparation: construction records intrinsic intent; prepare() resolves context; execution accepts only the prepared certificate.
- Inventory decisions and setup, preparation, execution, reporting, and interface consumers.
- Sketch
RunSpecification, candidate values, andPreparedRunrather than one undifferentiated bag. - Record bounded scalars, enums, optional selections, vector values, candidate sets, and default ownership.
- Keep intrinsic and cross-property rules in the specification; resolve data dimensions or host resources in
prepare(). - Make
execute()accept the prepared value so it does not repeat contextual checks. Define failure before expensive work. - Extract property specifications only when repeated declarations or several projections justify them.
- Define supported edits and projections to schemas, documentation, or interfaces.
- Test that preparation resolves deferred rules and execution contains no duplicate range or candidate-intent inference.
rtemis is the focused public exhibit for candidate intent, contextual bounds, and multiple projections.
Inspection is part of the contract, not print-method decoration. For each setting, expose both value and state:
- supplied — the caller chose the value;
- defaulted — R or the external authority supplied it under a named policy;
- resolved — preparation checked or derived it against actual context;
- pending — a declared contextual rule still lacks the inputs needed to decide it.
A printed RunSpecification should make pending checks visible; a PreparedRun should contain none. The trap is late rediscovery: execution re-infers whether a value was intentional and discovers a data-bound violation after expensive setup. The consequence is both wasted compute and a result that cannot explain which decisions were actually made.
10.7 Playbook 6: Designing an extension surface
Pressure. Third parties implement operations, so signatures alone are insufficient.
Opening. Other packages will implement your storage backend, provider, renderer, or engine. Draft verbs and laws before polishing the abstract class. The class gives dispatch a place to stand; the real product is a stable generic set, behavioral laws, capability protocol, and executable conformance suite.
- Inventory implementors and framework consumers.
- Sketch at least two implementations, including a small test implementation.
- Keep implementor-specific state out of the parent unless all implementations share its meaning.
- Put framework-checkable preconditions at the framework boundary and implementation-specific context inside methods.
- Define required generics, optional capabilities, results, failure policy, and laws before freezing hierarchies.
- Extract helpers after two implementations reveal common structure.
- Define compatibility and adaptation rules for contract evolution.
- Export a conformance suite that exercises laws, capabilities, and missing-method diagnostics.
An abstract parent gives dispatch a place to stand. It does not seal the family, prove every method exists, or replace a conformance suite.
The second implementation is not ceremonial. Build one realistic implementation and one deliberately small in-memory implementation before freezing signatures. The second exposes assumptions that accidentally belong to the first—filesystem paths in a generic, provider-specific state on the root, or a law that only one backend can satisfy. Run both through the same conformance function, including method-coverage and missing-capability cases.
The trap is a one-implementation interface. Its consequence is an API that looks extensible but requires the second implementor either to emulate the first backend’s accidents or to bypass the advertised contract.
10.8 Combining playbooks
Composition means assigning each concern its own authority and lifecycle, not blending the checklists. In a map-style package, the document follows the specification playbook, embedded expressions begin at the language playbook’s opaque rung, and build options use the configuration playbook. The style itself remains a value; no live-resource playbook is needed unless a renderer session or remote engine actually introduces shared identity.
An API client combines them differently. Authentication, retries, and rate limits belong to a thin live owner; typed request and response payloads follow the foreign-types or published-specification playbook; multiple provider backends invoke the extension-surface playbook and its conformance suite. The shared eight-step workflow keeps the joins explicit: each part names its consumers, rule horizon, failure channel, transformation boundary, and laws.
This matters because the playbooks intentionally disagree about first artifacts. A specification needs a gold-file slice, a translation needs a ledger, a language needs hand-written targets, configuration needs visible pending state, a live resource needs a fact/process cut, and an extension surface needs a second implementation. Combining them preserves those differences while giving the package one design vocabulary.
10.9 Choosing a playbook
The doctrine decision guides are the canonical compact reference for choosing representations, lifecycles, failure channels, conversion boundaries, and embedded-language depth. Return here once that decision identifies the kind of design work to perform; the playbooks above supply the corresponding sequence and verification steps.