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.1 The shared opening workflow

Use these eight moves for every S7 design.

10.1.1 1. Inventory meanings and consumers

Name the concepts the program cares about and the consumers that need them: constructors, operations, serializers, databases, interfaces, workers, or external validators. Record what each consumer currently infers or re-checks.

10.1.2 2. Sketch representative values and variants

Draw a small top-down slice before building foundations. Include an ordinary case, an edge case, and an unknown or extension case. Distinguish containment from references and value facts from live owners.

10.1.3 3. Inventory property contracts

For every property, record shape, cardinality, membership, bounds, caller-requiredness, stored presence, nullability, emptiness, default ownership, and wire omission. Decide whether meaning belongs to a reusable property or must travel as a semantic class.

10.1.4 4. Place rules at the smallest informed boundary

Put intrinsic rules on properties, relationships among one object’s fields on the class, cross-object rules on an aggregate root, and data-, host-, or authority-dependent rules in a named contextual operation. Do not ask a boundary to certify facts it cannot see.

10.1.5 5. Co-design operations and failure policy

List domain verbs, accepted concepts, result meanings, and laws. Decide where generic dispatch clarifies variation. State which failures are conditions, which are outcome values, and whether outcomes short-circuit or accumulate.

10.1.6 6. Extract only repeated recipes

After the representative slice works, extract repeated property factories, validators, or projection metadata. Important meanings may be named immediately; generalized machinery should earn its cost through repetition or multiple consumers.

10.1.7 7. Define transformations and projections

Name supported edits, parsers, serializers, renderers, schema projections, and host-binding steps. For each, state what is preserved, normalized, rejected, or lost. Reserve S7::convert() for faithful representation changes; name policy-bearing work parse_*(), adapt_*(), or compile_*().

10.1.8 8. Write acceptance laws

Test promised behavior and downstream simplification:

  • construction followed by supported replacement remains intrinsically valid;
  • preparing a run resolves every contextual bound before execution;
  • serialize then parse preserves a named equality relation;
  • render then reflect is semantically equal within documented dialect limits;
  • every known variant has methods for required operations;
  • consumers no longer repeat scalar, range, presence, or reference checks.

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.

  1. Include the specification, official validator, reference implementation, and representative documents in the consumer inventory.
  2. Sketch a vertical slice rather than transcribing the schema: one typed node, one open node, one reference edge, and one round trip.
  3. Record presence separately from nullability and defaults. External formats commonly distinguish omitted, null, empty, and defaulted values.
  4. Put document-wide references and uniqueness at the aggregate root; retain the external validator as conformance oracle.
  5. Design operations users need inside R. Those operations, not schema field count, determine which nodes deserve semantic classes.
  6. Extract recipes from recurring shapes or an owned machine-readable specification. Generated native classes are legitimate when source and authority are explicit.
  7. State the unknown-key and version policy per node. Preserve extensions only if serializers and transformations can keep them.
  8. 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.

  1. Inventory foreign meanings and both sides’ consumers.
  2. Sketch tagged unions, newtypes, optional fields, and extension records.
  3. Create a translation ledger for required, absent, null, defaulted, and unknown values. Do not collapse undefined, explicit null, and an absent key without policy.
  4. Separate transport checks from domain invariants and host-context rules.
  5. Import behavioral laws, not only shapes. Ownership, read-only declarations, or capabilities may need operations rather than properties.
  6. Extract translation recipes only after a construct recurs.
  7. Name direction and loss: decoding, encoding, adapting, or compiling are not automatically faithful conversions.
  8. 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.

  1. Inventory actual consumers: rendering, analysis, optimization, comparison, or execution.
  2. Sketch the smallest meaningful node family and write target artifacts by hand.
  3. Give nodes precise local contracts; leave embedded languages opaque when no operation interprets them.
  4. Place tree-wide name resolution and reference laws at the root.
  5. Define passes as operations. Analysis as well as rendering is strong evidence that typed nodes carry useful meaning.
  6. Extract smart constructors and node recipes after usage becomes repetitive.
  7. Distinguish faithful conversion, policy-bearing adaptation or compilation, and an IR earned by several sources, targets, or analyses.
  8. 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.

  1. Inventory decisions and setup, preparation, execution, reporting, and interface consumers.
  2. Sketch RunSpecification, candidate values, and PreparedRun rather than one undifferentiated bag.
  3. Record bounded scalars, enums, optional selections, vector values, candidate sets, and default ownership.
  4. Keep intrinsic and cross-property rules in the specification; resolve data dimensions or host resources in prepare().
  5. Make execute() accept the prepared value so it does not repeat contextual checks. Define failure before expensive work.
  6. Extract property specifications only when repeated declarations or several projections justify them.
  7. Define supported edits and projections to schemas, documentation, or interfaces.
  8. 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.6 Playbook 5: Binding portable definitions to live authority

Pressure. A valid request is not authorization, and a live process has identity that a portable value does not.

Opening. The system contains both portable descriptions and something with a lifetime: a session, connection, device, browser, conversation, or worker. Sort every noun into fact or process before choosing its object system. Then place the authority boundary between the portable request and the live owner.

  1. Inventory definitions, host policy, executable capabilities, owners, and completed outcomes.
  2. Sketch TaskDefinition, AuthorizedTask, a live runner, and TaskOutcome as distinct concepts.
  3. Put identifiers, resource requests, and symbolic capability names in portable values—not connections, closures, or secrets.
  4. Validate requested relationships locally; bind capabilities and enforce authority ceilings in a host-context operation.
  5. Decide which operational failures throw and which become named outcomes.
  6. Extract permission and resource recipes after concrete tasks reveal repetition.
  7. Define serialization before binding and projections of runtime state to inspectable values.
  8. Test that serialization contains no executable resources, unauthorized requests fail before effects, and outcomes require no flag interpretation.

deputy illustrates tools, permissions, limits, and a live agent run.

The lifecycle argument is causal. Aliases must observe one connection or in-progress stream, so that process needs reference identity. Completed requests, turns, settings, and outcomes no longer evolve, so they become S7 values that can be compared, serialized, and tested without the resource. The owner should therefore stay thin: lifecycle and orchestration around value-in/value-out operations. If a value carries a reference payload, state that explicitly because replacement and outer validation do not observe mutation inside the payload.

The trap is turning facts into owners for convenience. Its consequence is defensive cloning, shared-state test setup, and uncertainty about whether two callers are looking at the same evolving thing or two equal facts.

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.

  1. Inventory implementors and framework consumers.
  2. Sketch at least two implementations, including a small test implementation.
  3. Keep implementor-specific state out of the parent unless all implementations share its meaning.
  4. Put framework-checkable preconditions at the framework boundary and implementation-specific context inside methods.
  5. Define required generics, optional capabilities, results, failure policy, and laws before freezing hierarchies.
  6. Extract helpers after two implementations reveal common structure.
  7. Define compatibility and adaptation rules for contract evolution.
  8. 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.