ADR-0016: Generator Protocol — Generators Are Objects Found Through One Entry-Point Group
Status: Accepted
Date: 2026-11-01
Source: milestone M018/S01 and S02; backlog K3; decisions D056 and D057
Context
Until M018 a generator was a pair in GENERATORS in seams_gen/__main__.py: a help text and a generate(trig, out) function returning {file name: text}. The model elements each file came from were a separate table, SOURCES in seams_gen/sources.py. seams generate accepted only the names of that dict, and seams.lock recorded seams_gen.__version__ for every output.
That left three gaps:
- No way in for a generator from somewhere else. A team that wrote its own generator, as the Write your own generator guide shows, could run it by hand but could not list it in
seams.toml. Its outputs were not inseams.lock, soseams checkcould not report them as stale or hand-edited. - One version for all. The lock's
generator_versionwas the version ofseams_gen, even for a file another package wrote. - No stated input contract. A generator that needs, say, every
seam:Modelto have a notation found that out half-way throughextractand raisedGeneratorErroron the first gap. The shapes already state most of these facts (K1, M017/S01), but nothing connected a generator to the shapes it relies on.
Decision
The protocol
A generator is a seams_gen.protocol.Generator: a frozen, keyword-only dataclass with
| Field | Type | Meaning |
|---|---|---|
name | str | the name seams.toml and python -m seams_gen use; equal to the entry-point name |
version | str | recorded per output in seams.lock as generator_version |
help | str | one line, shown by python -m seams_gen --help |
extract | (trig: Path, out: Path) -> V | reads the model into a frozen view model V. Every check and every computation is here; a missing or ambiguous fact raises GeneratorError |
render | (view: V) -> dict[str, str] | the view model as {relative file name: text}; no model access |
sources | (trig: Path, out: Path) -> dict[str, tuple[str, ...]] | {relative file name: full IRIs} of the model elements each file comes from, with exactly the file names render returns |
preconditions | tuple[str, ...], default () | full IRIs of the sh:NodeShapes the model must conform to before the generator runs |
Generator.generate(trig, out) is render(extract(trig, out)). The class is generic in V, so a type checker sees that render takes what extract returns. out is passed to extract for generators whose output names paths relative to it (compose).
The seven built-ins (compose, dockerfile, fastapi, htmx, ifmledit, smoke, tests) are module-level objects in seams_gen/builtin.py, each split into a real extract and render step over its existing extract_* and render_* functions. Their version is seams_gen.__version__, their sources come from seams_gen/sources.py, and they declare no preconditions. Every golden and committed output is byte-identical.
Inputs beyond the model (amendment, M027/S02)
A generator should read only the model and its payloads, but one fact may live in a file the model does not hold. The dockerfile generator reads the uv workspace members from the pyproject.toml at the root of the build context, and each member's pyproject.toml (backlog A12). Tools that regenerate in a scratch copy hold only the model files, or only the project directory: the viewer's write-back (viewer/writeback.py) and seams suggest. With such a file missing from the copy, they refused every edit.
So a generator that reads such a file names it. Generator has an optional field:
| Field | Type | Meaning |
|---|---|---|
inputs | (trig: Path, out: Path) -> tuple[Path, ...], or None (default) | the absolute paths of the files beyond the model and its payloads that extract reads. It never raises: a file it cannot find is left out, and extract raises the GeneratorError |
seams_cli.generator_inputs(project, model) is the sorted union over a project's generators. A tool that regenerates in a scratch copy copies these files to the same place relative to the model: the write-back adds them to the files it copies, and seams suggest lays its copy out from the common directory of the project and the inputs. A new scratch-copy path must do the same. Of the built-ins, only dockerfile has inputs (seams_gen.dockerfile.dockerfile_inputs).
The output still depends only on what is declared, so principle 6 ("Same model, same bytes") holds for the model plus the declared inputs. seams.lock does not record the inputs' hashes: seams check regenerates, so a changed input shows up as an out-of-date output.
Rejected alternatives: state the member list in seams.toml or the model, which keeps the model the only input but repeats the workspace in two places; and have each scratch copy copy the workspace files itself, which spreads knowledge of one generator into the viewer and the CLI.
Discovery: the seams.generators group
seams_gen.registry.discover() loads every entry point of the seams.generators group (importlib.metadata.entry_points) and returns {name: Generator} sorted by name. A distribution registers a generator with
[project.entry-points."seams.generators"]
checklist = "seams_checklist:generator"discover() raises seams_gen.registry.RegistryError, a GeneratorError, when an entry point does not load, loads an object that is not a Generator, loads a generator whose name differs from the entry-point name, or repeats a name another entry point has already registered. The message names the entry point (name = value) and its distribution with version, so the user knows which package to fix or remove. Entry points are visited in (name, value) order, so the same environment always reports the same error.
seams_gen.__main__.GENERATORS and seams_gen.sources.output_sources are views of the registry, so python -m seams_gen <name> runs a third-party generator too. seams.toml accepts any discovered name; an unknown one is still a ProjectError that lists the known names.
Where the built-in entry points live (D056)
The built-ins' entry points are declared in packages/seams-cli/pyproject.toml (semanticseams-cli), pointing at seams_gen.builtin:<name>. Entry points are distribution metadata, and seams_gen is a virtual workspace member that installs no metadata of its own (D033). semanticseams-cli is the one installed member that imports seams_gen, so declaring them there gives built-in and third-party generators one discovery path without moving any code (D031). When seams_gen becomes a real package, the entry points move into its own pyproject.toml.
The lock records each generator's version
seams generate carries each generator's version in its Output, and lockfile.build_lock writes it as that output's generator_version. The lock schema (lockfile_version 1) is unchanged, so existing seams.lock files regenerate with no diff: the built-ins' version is still seams_gen.__version__.
Strict precondition checking
Before any generator runs, seams generate checks the preconditions of every generator in the project that declares some. It validates the model, unioned with ontology/*.ttl, against ontology/shapes with pyshacl once, and passes the report to each generator's Generator.check_preconditions(results, shapes). A result counts when its sh:sourceShape is a precondition node shape or one of that shape's sh:property shapes (matched in the same parsed shapes graph, so blank-node property shapes match).
Any such result raises GeneratorError whose subject is the focus node and whose predicate is the shape IRI; the message names the generator and the shape's sh:resultMessage, plus a count of further results. Results are sorted by (shape, focus node, message), so the error is deterministic. The CLI prints it and exits 2, and nothing is written: no outputs and no seams.lock.
seams generate: https://example.org/expenses/broken_model: https://w3id.org/seams/shapes#ModelShape: generator 'strict-gen' requires the model to conform to this shape: Every seam:Model has exactly one seam:notation, and it is a seam:Notation.seams_gen runs no validation itself (its only use of pyshacl is the test kit's parity helpers): seams_cli runs the validation and hands the report over, so the check sits in seams_gen/protocol.py, where the shape/generator parity inventory (tests/test_shape_generator_parity.py) lists it as GENERATOR_ONLY (the check is the shapes). Results of shapes that are not preconditions are ignored here; seams check still reports them.
Lenient mode flags the lock (D057)
Strict mode is the default and is unchanged. seams generate --lenient is for previews of a model that is still broken (backlog K3, I3): it collects faults instead of raising.
- Each precondition result becomes a fault, and the generator still runs.
- A generator that raises
GeneratorErroris caught. It writes nothing, adds a fault, and the files it wrote last time are kept and stay in the lock rather than being deleted as orphans, so a later clean run can still remove them safely.
Each fault is {generator, shape, focus, message}. For a raised GeneratorError, shape is the error's predicate and focus its subject. The CLI prints one line per fault to stderr, then a summary, and exits 0:
seams generate: fault strict-gen: https://example.org/expenses/broken_model: https://w3id.org/seams/shapes#ModelShape: generator 'strict-gen' requires the model to conform to this shape: Every seam:Model has exactly one seam:notation, and it is a seam:Notation.
seams generate: 1 fault(s); output is flaggedseams.lock records the faults as a sorted top-level faults list. A lock with faults is flagged: seams check reports a flagged finding (<lock path> N fault(s)), which is a problem and exits 1, so the CI action rejects flagged output. seams check itself generates leniently, so a generator fault shows up as flagged when the lock records it, or as lock-out-of-date when it does not; it never exits 2.
The flag lives in the one artifact that seams check and CI already read, so generated files stay deterministic and carry no markers, and principle 5 (flag invalid models, never refuse them) holds for previews. lockfile_version stays 1: a lock with no faults has no faults key and is byte for byte what a strict run writes.
The test kit (M018/S03)
A generator from another distribution needs the same checks the built-ins' tests use, so they are public in seams_gen.testing (backlog K4): golden_target and assert_matches_golden (the golden-target rule, D016, with the sha256:<model-hash> placeholder), assert_deterministic (two runs of Generator.generate give the same files), check_output (the output parses as Python, YAML, JSON or a Jinja2 template) and the K1 parity helpers shape_violations and assert_shapes_catch (a fault the generator rejects is also a shape violation). The built-ins' tests and tests/test_shape_generator_parity.py use the module, and tests/test_generator_kit_third_party.py tests a generator defined in the test with it alone. The kit is imported as seams_gen.testing, not re-exported from seams_gen, so pyshacl, which only the parity helpers need, loads only when they run.
Alternatives Considered
- Keep
GENERATORSand add apluginskey toseams.tomlnaming import paths. Rejected: built-ins and third-party generators would be found two ways, and a project file would hold Python import paths rather than names. Entry points are the standard way for an installed distribution to offer plugins. - Declare the built-in entry points in
seams_gen/pyproject.toml. Not possible whileseams_genis virtual (D033): a virtual member installs no.dist-info, so the entry points would never be visible. - A
Generatorprotocol class (typing.Protocol) instead of a dataclass. Rejected:discover()needs a runtime check that an entry point loads a generator, and a frozen dataclass gives that withisinstance, plus immutability and keyword-only construction. - Run pyshacl inside
seams_gen. Rejected: it would validate once per generator instead of once per run, for a check only the CLI needs. - Record
seams_gen.__version__for every output, as before. Rejected: a third-party generator's output would carry a version unrelated to the code that wrote it.
Consequences
- A generator from another distribution is listed in
seams.toml, run byseams generate, recorded inseams.lockwith its own version and audited byseams checklike a built-in. - Installing two distributions that register the same name, or a broken plugin, makes every
seams generateandpython -m seams_gencall fail with a message naming the entry point and distribution, rather than silently picking one. - A generator can state the shapes it relies on and get a shape-level error before
extractruns, instead of the firstGeneratorErrorit meets. - A generator that reads a file beyond the model declares it with
inputs, and every scratch-copy regeneration carries it, so the viewer andseams suggestregenerate asseams generatedoes. seams generatevalidates with pyshacl only when some generator in the project declares preconditions, so projects using only the built-ins pay nothing.
Implementation Notes
- Protocol:
seams_gen/protocol.py. Built-ins:seams_gen/builtin.py. Registry:seams_gen/registry.py. Entry points:packages/seams-cli/pyproject.toml; after changing them, runuv sync --all-packagesso the editable metadata refreshes. - CLI:
packages/seams-cli/src/seams_cli/project.py(names),generate.py(preconditions,Generator.generate,Generator.sources),lockfile.py(per-output version). - Tests:
tests/test_generator_protocol.py(built-ins equal the old functions),tests/test_generator_registry.py(discovery and eachRegistryError, with a fake.dist-infoonsys.path),tests/test_seams_cli_generate.pyandtests/test_seams_cli_lockfile.py(a third-party generator throughseams generate, a failing precondition, its version in the lock). - The guide: Register it with seams generate.