Packages
A BEAM-first process-mining substrate manufactured entirely by ggen from ontology.ttl + a vendored ggen-marketplace pack: real OCEL discovery/conformance across Erlang, Elixir, Gleam, and Ash projections, plus native engine facades (petgraph, tract, rust4pm, ferroplan) hosted in BEAM via wasm.
Current section
Files
Jump to
Current section
Files
CONTRIBUTING.md
# Contributing to beam4pm
beam4pm's application source is manufactured, not written. Most of `src/`, `lib/`,
`test/`, `gleam/`, `schema/`, and `docs/reference/` is the output of `ggen sync run` (and,
for a few modules, `mix ggen_igniter.sync`) acting on two real inputs: this repo's
`ontology.ttl` and the templates in the vendored `beam4pm-process-model-pack`
(`vendor/ggen-marketplace/packs/beam4pm-process-model-pack`, a git submodule). There is
no third way to change what ships. See
[`docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md`](docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md)
for the full doctrine (`A = μ(O*)`); this document is the operational how-to.
**There is no `generated/` directory.** Ggen-manufactured code lives in the exact same
conventional directories hand-authored code would use — `src/beam4pm.app.src` sits right
next to ggen-manufactured `src/beam4pm_types.erl`, and `mix.exs`'s Mix defaults compile
all of `lib/` and run all of `test/` without a custom `elixirc_paths`/`test_paths` split.
Segregating manufactured output into a special second-tier directory would treat it as
lesser than hand-written code; it isn't — it's the *only* application source this repo
has. Provenance is signalled per-**file**, not per-directory: every manufactured file's
first non-shebang line reads `# GENERATED by ggen ... Do not edit.` (or the Erlang/Elixir
equivalent).
## 1. Never edit a file carrying the GENERATED marker
Any file whose first line says `GENERATED by ggen from the admitted ... graph. Do not
edit.` is manufactured output, checked in for reviewability but not for hand-editing,
*regardless of which directory it lives in*. A PR that hand-edits such a file directly,
instead of through regeneration, will be rejected — this holds for humans and LLMs alike.
`scripts/gate_m2_check.sh` finds every manufactured file by this exact marker (never by
directory) and is the authoritative definition of what "generated" means in this repo.
There are exactly two legitimate ways to change generated code:
1. **Edit `ontology.ttl`** to add or change an admitted `bpm:RecordType` — the normal
path for adding a new process-mining record, adding/renaming/retyping a field, or
changing a field's required-ness or doc string. This is a change to *this* repo.
2. **Propose a change to the pack's templates** — the 26 Tera templates under
`templates/` (types/tests/codec/discovery/roundtrip/manifest for Erlang and Elixir,
the five Gleam templates, JSON Schema, Markdown reference, test helper) or the
ggen_igniter EEx assets under `igniter/` (the Ash projection and its CRUD test
suite, rendered by `scripts/igniter_sync.sh` via `mix ggen_igniter.sync` instead of
the Rust ggen binary) — the path for anything that isn't expressible as instance
data under the existing `bpm:` vocabulary: a new generated function shape, a
different constructor error format, additional test cases, a new target language.
`vendor/ggen-marketplace` is a separate repository
(`seanchatmangpt/ggen-marketplace`) vendored as a git submodule, so this kind of
change is a PR against *that* repo, then a submodule bump
(`git -C vendor/ggen-marketplace pull && git add vendor/ggen-marketplace`) here to
pick it up.
If you're not sure which of the two a change needs: if it's a fact about a record (its
name, its fields, a field's type/doc/required-ness), it's (1). If it's a fact about *how*
records in general become Erlang/Elixir code, it's (2).
## 2. The record-type Turtle shape
The `bpm:` vocabulary (declared once, in the pack's own `ontology.ttl`, not repeated
here) has exactly two classes and eight properties between them:
| Class | Property | Range | Meaning |
|------------------|---------------------|---------------|-----------------------------------------------------------------------|
| `bpm:RecordType` | `bpm:recordName` | `xsd:string` | snake_case record/module name, e.g. `"ocel_event"` |
| `bpm:RecordType` | `bpm:recordDoc` | `xsd:string` | one-line doc comment for the generated record/struct |
| `bpm:RecordType` | `bpm:hasField` | `bpm:Field` | one or more ordered fields belonging to this record type |
| `bpm:Field` | `bpm:fieldName` | `xsd:string` | snake_case field name, e.g. `"event_id"` |
| `bpm:Field` | `bpm:fieldType` | `xsd:string` | one of the closed 8-value enum below |
| `bpm:Field` | `bpm:fieldDoc` | `xsd:string` | one-line doc comment for the generated field |
| `bpm:Field` | `bpm:fieldRequired` | `xsd:string` | the literal `"true"` or `"false"` — a validating constructor's contract |
| `bpm:Field` | `bpm:fieldOrder` | `xsd:integer` | 1-based stable ordering of the field within its record type |
All eight are required. `gates/010_required.rq` in the pack refuses (returns rows for)
any `bpm:RecordType` missing `recordName`/`recordDoc`/`hasField`, or any `bpm:Field`
missing `fieldName`/`fieldType`/`fieldDoc`/`fieldRequired`/`fieldOrder`.
`bpm:fieldType` is a closed 8-value enum — `gates/020_field_type_enum.rq` refuses any
`bpm:Field` whose type isn't one of these:
| `bpm:fieldType` | Erlang | Elixir |
|-----------------|--------------|---------------------------------------------|
| `string` | `binary()` | `String.t()` |
| `integer` | `integer()` | `integer()` |
| `float` | `float()` | `float()` |
| `boolean` | `boolean()` | `boolean()` |
| `datetime` | `binary()` | `String.t()` (ISO8601 string on the wire) |
| `atom` | `atom()` | `atom()` |
| `list_string` | `[binary()]` | `[String.t()]` |
| `map` | `map()` | `map()` |
Two literal-syntax traps, both learned the hard way (see `receipts/2026-08-29-first-ggen-manufactured-slice.json`):
- `bpm:fieldRequired` must be a **plain string literal**, `"true"` or `"false"` — not an
`^^xsd:boolean`-typed literal. The templates compare it as a string.
- A Turtle numeric literal never carries an explicit `^^xsd:integer` suffix — write
`bpm:fieldOrder 3 .`, not `bpm:fieldOrder 3^^xsd:integer .` (the bare integer already
*is* `xsd:integer`; only a quoted string needs the suffix, e.g. `"3"^^xsd:integer`).
Only the property values above drive codegen — the IRI local name you give an individual
(`bpm:ocel_event_rt`, `bpm:ocel_event_event_id_f`, ...) is never read by any template. The
`<record>_rt` / `<record>_<field>_f` naming convention used throughout `ontology.ttl` is
there purely so a human reading the file can tell which fields belong to which record; use
it for new entries so the file stays consistent, not because ggen requires it.
### Worked example: adding a new record type
Say you want to add a `case_variant` record (a case's activity sequence, with its
observed frequency) — two required string/integer fields. Append this to `ontology.ttl`:
```turtle
# ---------------------------------------------------------------------------
# 9. case_variant
# ---------------------------------------------------------------------------
bpm:case_variant_rt a bpm:RecordType ;
bpm:recordName "case_variant" ;
bpm:recordDoc "One distinct activity-sequence variant observed across cases." ;
bpm:hasField bpm:case_variant_variant_id_f ,
bpm:case_variant_frequency_f .
bpm:case_variant_variant_id_f a bpm:Field ;
bpm:fieldName "variant_id" ;
bpm:fieldType "string" ;
bpm:fieldDoc "Stable identifier for this variant (e.g. a hash of its activity sequence)." ;
bpm:fieldRequired "true" ;
bpm:fieldOrder 1 .
bpm:case_variant_frequency_f a bpm:Field ;
bpm:fieldName "frequency" ;
bpm:fieldType "integer" ;
bpm:fieldDoc "Number of cases that followed this exact variant." ;
bpm:fieldRequired "true" ;
bpm:fieldOrder 2 .
```
Nothing else needs to change. Run the regeneration loop below and you get, for free:
an Erlang `-record(case_variant, {...})` plus `new_case_variant/1` in
`src/beam4pm_types.erl`, an Elixir `BeamPM.Types.CaseVariant` struct
plus `new/1` in `lib/beam4pm_types.ex`, and one ok-path + one
missing-required-field EUnit test and ExUnit test each, for exactly this new record.
## 3. Regeneration and verification loop
Every change to `ontology.ttl` or to the pack's templates must be followed by this full
loop before you open a PR. All commands run from the repo root.
```sh
# 1. Force a clean regeneration (don't trust a stale lock file).
rm -f ggen.lock
ggen sync run
bash scripts/igniter_sync.sh # the second manufacturing engine (Ash projection)
bash scripts/actuation_sync.sh # the third (Reactor BRCE actuation)
# 2. Run both real BEAM test suites against what was just generated.
rebar3 eunit
mix test
# 3. Determinism check: prove ggen is a pure function of (ontology.ttl, pack templates),
# with no dependency on what was already sitting on disk. Provenance is per-FILE (the
# GENERATED marker), not per-directory -- this script finds every manufactured file by
# that marker, deletes exactly those, regenerates with both engines, and diffs.
bash scripts/gate_m2_check.sh
```
If step 3 fails, the pack (or ggen/ggen_igniter itself) is not deterministic for
your change — that is a bug to fix before merging, not a diff to accept. `just verify`
(and its lighter, legacy `make verify` alias) run submodule init + the sync step
(dry-run then real) + both test suites in one shot; `just verify` additionally runs
GATE AUTHORSHIP, GATE ENGINE DISPATCH, and GATE LINT-TRUTH (`make verify` predates all
three and does not run them). CI (`.github/workflows/beam4pm-ci.yml`) runs those same
three gates as explicit steps, plus GATE M5 (`roundtrip_check.sh`) and, separately,
GATE M2 (`gate_m2_check.sh`) — but neither `make verify` nor `just verify` runs the
determinism check itself, so always run step 3 by hand for anything that touches
`ontology.ttl` or the pack's templates.
ggen.lock is intentionally absent at this pin (deleted in 3265c20f); the
regeneration flow re-locks from the pinned producers.
Toolchain this was last verified against locally: `ggen 26.8.28`, `rebar3 3.27.0` on
OTP 28 (erts-16.2), `mix`/Elixir 1.19.5. CI (`.github/workflows/beam4pm-ci.yml`) pins a
different but also-compatible combination (OTP 27.2 / Elixir 1.18.5 / rebar3 3.24.0) via
`erlef/setup-beam` — either combination should pass `rebar3 eunit`/`mix test`
identically, since nothing here depends on OTP/Elixir version-specific behavior;
if you find a real discrepancy between the two, that itself is worth reporting.
Don't forget `git clone --recurse-submodules` (or
`git submodule update --init --recursive`) first — `vendor/ggen-marketplace` is a
submodule and `ggen sync run` will fail to resolve the pack without it.
## 4. The privileged-source-exception path (rare)
If something genuinely cannot be expressed as ontology data plus a template — not "this
is inconvenient to model," but "this cannot be derived from `bpm:RecordType`/`bpm:Field`
facts by any template" — a direct edit to a file carrying the GENERATED marker may be
admitted, but only through the scoped, receipted exception process in
[`docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md`](docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md#privileged-exception),
never as a silent hand-edit. That process requires, before any edit is made:
- the exact subject/path being touched;
- the exact intended change;
- the authorizing principal;
- a stated reason manufacturing is currently disproportionate for this case;
- the acceptance command/behavior that will prove the change works;
- a pure-commit requirement — the commit contains only the admitted manual
intervention, nothing else riding along;
- an expiration for the privilege.
The resulting commit must be accompanied by a receipt (see
`receipts/2026-08-29-first-ggen-manufactured-slice.json` for the shape this project's
receipts take) and by a real, Chicago-style execution — a real test run, not a mock —
against the exact subject that was hand-changed, not just "the suite still passes."
Privilege terminates immediately after the intervention: the next legitimate change to
that same subject goes back through ontology/template regeneration, not through another
manual edit. If in doubt whether your case qualifies, open an issue describing what you
tried to model in `ontology.ttl` or the pack's templates and why it didn't work, rather
than opening a PR that hand-edits a GENERATED-marked file directly.
### Unmarked hand-authored files under manufactured roots (admitted, counted debt)
The path above covers edits to files that already carry the GENERATED marker. The
other shape of the same exception is a file under a manufactured root (`src/`, `lib/`,
`test/`, `gleam/src`, `gleam/test`, `schema/`, `docs/reference/`, `infra/gcp/cloudrun`,
`infra/gcp/packer`) that carries no marker at all — a native-engine facade, a
hand-written qualification file. Until 2026-09-05 nothing tracked these (33 existed
with zero admission records). They are now governed mechanically by GATE AUTHORSHIP
(`scripts/gate_authorship_check.sh`, run by `just verify`, by CI, and as a subprocess by
the manufactured `test/beam4pm_authorship_gate_test.exs`):
- every such file must have one `bpm:HandAuthoredSource` individual in `ontology.ttl`
(vocabulary: the vendored `beam4pm-process-model-pack`'s `ontology.ttl`) carrying the
same capability-object fields as above — `bpm:sourcePath`, a closed
`bpm:authorshipKind`, `bpm:authorizingPrincipal`, `bpm:admissionReason`,
`bpm:acceptanceCommand` (a command `just verify`/CI really runs; `--exercise` refuses
a failing or all-skipped one), `bpm:admittedAtCommit`, and for debt kinds
`bpm:contentSha256`, `bpm:admissionExpires`, `bpm:sunsetPlan`;
- the admitted set is rendered by ggen into `schema/beam4pm_hand_authored_source.tsv`
(what the gate reads — never a hand-maintained list) and the counted ledger
`docs/reference/beam4pm_hand_authored_source.md`;
- the gate refuses an unmarked file with no admission (`REFUSED_UNADMITTED`), an
admission whose file is gone (`REFUSED_STALE_ADMISSION`) or now carries the marker
(`REFUSED_CONTRADICTION`), an edit to an admitted debt file that was not re-admitted
with its new sha256 (`REFUSED_SHA_DRIFT` — an edit to admitted hand-authored source
is new debt, not a free ride), and an admission past its expiry (`REFUSED_EXPIRED`);
- pack gates refuse an individual missing a field, naming an unknown kind, duplicating
a path, or pushing a kind past its `bpm:debtCeiling`.
So adding or editing a hand-authored file under a manufactured root means editing
`ontology.ttl` in the same commit (new/updated individual, new sha256) and regenerating
(`rm -f ggen.lock && ggen sync run`); the diff to the manifest and the ledger IS the
visible, counted debt. The preferred move is still to eliminate the file by rendering
it from a template — every admission carries its own `bpm:sunsetPlan` saying how. Two
of the four native-engine facades (`petgraph`, `tract`) have already taken that path —
see `docs/reference/beam4pm_hand_authored_source.md` for the current admitted/debt
counts and which two remain (`rust4pm`, `ferroplan`).
## Running the RF1/RF2/RF3 oracle-backed qualification tests
`test/beam4pm_rf1_dfg_test.exs`, `test/beam4pm_rf2_conformance_test.exs`, and
`test/beam4pm_rf3_ocel_test.exs` are real differential-testing suites: they invoke a
real native Rust oracle binary (built from `process_mining` 0.6.2) via a `Port`, not a
mock. `native/*/target/` is gitignored (see the `native/*/target/` entry in
`.gitignore` — Cargo.lock is committed for reproducible builds, `target/` is not), so
each contributor/CI job builds the oracle binaries locally before running these three
suites:
```sh
cd native/rf1-dfg-oracle && cargo build --release && cd -
cd native/rf2-conformance-oracle && cargo build --release && cd -
cd native/rf3-ocel-oracle && cargo build --release && cd -
```
RF1 (`beam4pm_rf1_dfg_test.exs`) resolves its own oracle path at
`native/rf1-dfg-oracle/target/release/rf1-dfg-oracle` (no env var). RF2 and RF3 take
their oracle/fixture paths from env vars — not hardcoded, so the suite is portable
across machines/CI:
```sh
RF2_ORACLE_BIN="$(pwd)/native/rf2-conformance-oracle/target/release/rf2-conformance-oracle" \
RF2_CLEAN_XES="$(pwd)/qualification/fixtures/receipt.xes" \
RF2_MUTATED_XES="$(pwd)/qualification/fixtures/mutated_receipt.xes" \
RF3_ORACLE_BIN="$(pwd)/native/rf3-ocel-oracle/target/release/rf3-ocel-oracle" \
RF3_POSITIVE_FIXTURE="$(pwd)/qualification/fixtures/positive-self-authored.ocel.json" \
RF3_N13_FIXTURE="$(pwd)/qualification/fixtures/n13-duplicate-object-id.ocel.json" \
RF3_N14_FIXTURE="$(pwd)/qualification/fixtures/n14-undeclared-event-type.ocel.json" \
RF3_N05_FIXTURE="$(pwd)/qualification/fixtures/n05-o2o-dangling.ocel.json" \
mix test
```
These env vars are genuinely machine/build-specific (an absolute path into a
gitignored `target/` dir) — they do not belong in a committed `.env.test` file, only
in this documented recipe. Without them, `RF2ConformanceTest`/`RF3OcelTest` fail fast
via `System.fetch_env!/1`, and `RF1DfgDiscoveryTest` raises a clear "binary not found,
run `cargo build --release` in native/rf1-dfg-oracle/ first" message — both are
honest refusals, not silent skips.
## Versioning
beam4pm uses CalVer: version tags have the shape `vYY.MM.P` (e.g. `v26.9.10`,
`v26.10.1`) and are cut on release commits; `mix.exs`'s `version:` (currently
`26.10.1`) is bumped on release day — bump it via
`mix beam4pm.version_bump <version>` (`lib/mix/tasks/beam4pm.version_bump.ex`),
never by hand-editing `mix.exs`. The declared version must match the topmost
released `CHANGELOG.md` heading.
### The ex4pm exact-pin policy
`mix.exs` pins `{:ex4pm, "== 26.10.1"}` exactly — not a range — because ex4pm's
CalVer third component carries **contract changes**, not patch fixes; a
`~>` range could silently admit a breaking ex4pm release. The consequence:
every ex4pm bump requires a matching dependent release here (a new `vYY.MM.P`
tag and `mix.exs`/CHANGELOG bump in the same cycle), so the pin and the
released contract never drift apart.
## See also
- [`README.md`](README.md) — project overview, current manufacturing status
- [`scripts/gate_m2_check.sh`](scripts/gate_m2_check.sh) — the authoritative, marker-driven definition of "generated" in this repo
- [`scripts/gate_authorship_check.sh`](scripts/gate_authorship_check.sh) — GATE AUTHORSHIP: every unmarked file under a manufactured root must be an admitted, counted `bpm:HandAuthoredSource`
- [`docs/reference/beam4pm_hand_authored_source.md`](docs/reference/beam4pm_hand_authored_source.md) — the generated, counted ledger of admitted hand-authored files
- [`docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md`](docs/jira/v26.8.29/03-architecture-and-ggen-manufacturing.md) — full architectural doctrine and the privileged-exception section
- [`vendor/ggen-marketplace/packs/beam4pm-process-model-pack/README.md`](vendor/ggen-marketplace/packs/beam4pm-process-model-pack/README.md) — what the pack's templates generate, file by file