Aligning the Go wrapper to a new libhegel release
libhegel is a Rust cdylib built from hegel-rust's hegel-c crate. hegel-go
calls it via purego FFI. When the pinned version changes, the C API in
hegel-c/include/hegel.h may have added, removed, renamed, or re-typed symbols,
and the low-level wrapper in internal/libhegel/ must be re-aligned. The main
hegel package's exported API must not change as a result — only the
internal binding layer.
Within that binding layer, the wrapper API should track the C API. When a
C function gains a parameter, plumb it through the internal/libhegel wrapper
method (Settings.RunStart, …) rather than hardcoding a constant inside the
wrapper — the wrapper's exported signature is meant to mirror the C signature.
Any behavior-preserving default for a new optional parameter (a NULL callback,
a nullable pointer, a 0-means-default flag) belongs in the package-hegel
callers (runner.go, …), which pass 0/NULL — not baked into the wrapper.
This keeps the binding honest (a future caller can supply a real value) while
holding the hegel package's public API stable. Example: hegel_output_callback_t
added (callback, user_data) to hegel_run_start; the wrapper takes both and
the runner passes 0, 0.
The context-based ABI
Every fallible libhegel call follows one convention, and the whole wrapper is
shaped around it:
- The first argument is a
hegel_context_t (an error-reporting context).
- The return value is an
Error result code (OK is 0; failures are
negative — E_INVALID_HANDLE, E_INVALID_ARG, …).
- Any value the call produces (a handle, a count, a bool, a byte buffer) is
written through a trailing out-parameter, not returned.
- On a non-
OK return, the human-readable message is read back from the context
via hegel_context_last_error.
So symbols (the loaded library) is immutable and shared across goroutines,
while the per-call error state lives on a *Context. The wrapper threads a
*Context through every method and funnels every call through two helpers:
ctx.invoke(op, fn) — runs fn(ctxT), and if it returns non-OK, reads
hegel_context_last_error and wraps it into a Go error.
allocate(ctx, op, new, free) — invoke for handle constructors: maps a
zero out-handle to a nil result (the engine's "no object" sentinel, e.g. a
finished run) and registers a GC cleanup that calls free.
1. Find the pinned version and fetch the matching header
The version lives in internal/libhegel/version.go as hegelVersion (bump it
with just vendor-libhegel <version>, which also refreshes the vendored
binaries under internal/libhegel/libs/).
The release tag is v<VERSION> (note the v prefix — the raw path without
it 404s):
curl -sSL https://raw.githubusercontent.com/hegeldev/hegel-rust/v<VERSION>/hegel-c/include/hegel.h
2. Get the matching library — always just check vendored
just check runs integration tests (TestLoadLibVersion, TestLibhegelEndToEnd)
against the real library, and TestLoadLibVersion asserts the loaded lib
reports exactly hegelVersion. You need a libhegel at that exact version, and
the simplest way to get it is to let the loader use the vendored binary:
just check vendored
Always use vendored mode for this work — never a local build. The
vendored mode leaves HEGEL_LIBHEGEL_PATH unset, so the loader materializes
the go:embed'd vendored binary for hegelVersion (under
internal/libhegel/libs/, refreshed by just vendor-libhegel) into the cache.
A plain just check would instead build whatever the sibling ../hegel-rust
checkout happens to be on (likely an older tag, since you've just bumped the
version), point HEGEL_LIBHEGEL_PATH at that stale .so, and fail the version
test. Don't touch the checkout; the vendored binary is always the right version.
The cached library lands at:
${XDG_CACHE_HOME:-$HOME/.cache}/hegel-go/libhegel/<VERSION>/libhegel-linux-amd64.so
When you need the symbol table, run nm -D against that cached .so — no
build required. Resolve the path with a glob so you don't have to spell out the
version:
LIB=$(ls "${XDG_CACHE_HOME:-$HOME/.cache}"/hegel-go/libhegel/*/libhegel-linux-amd64.so | tail -1)
nm -D "$LIB" | grep '^.* T hegel_' | sort
The nm output is ground truth for which hegel_* symbols exist — diff it
against the registration table in tryOpen (libhegel.go). A symbol the wrapper
registers but the lib no longer exports makes registerSymbols fail and
every integration test dies at load.
3. Compare hegel.h against internal/libhegel/libhegel.go
Walk every C declaration and check it against the wrapper. Categorize each:
- Removed symbol → delete its
symbols field, its registration-table entry,
its stub.go closure, and any wrapper method. Re-route callers.
- Renamed/retyped symbol (e.g.
hegel_run_result_passed, a bool, became
hegel_run_result_status, a hegel_run_status_t written through an out-param)
→ update the field signature, the registration name, the wrapper method, and
the stub.go closure's signature/out-param handling.
- New symbol → add a
symbols field, a registration entry, a wrapper method,
a stub.go closure, and a test (see §5).
- Changed signature (a new arg, or a value that moved from return to
out-param) → remember the ABI convention: ctx first,
Error return,
produced value through a trailing out-pointer. The symbols field type and the
wrapper method change together (stub.go adapts on its own — see §4.3). A new
arg must be plumbed through the wrapper method's signature, not hardcoded
inside the wrapper. If it is an optional arg with a behavior-preserving
default (a NULL callback, a nullable pointer, a 0-means-default flag), leave
the wrapper faithful and pass the default from the package-hegel callers
(see the charter note in §intro). Do not silently bury a default in the
wrapper — that hides the new capability and the §8 audit cannot tell a
deliberate default from an oversight.
- Changed enum/constant values (e.g. a new
HEGEL_LABEL_*, or a reordered
hegel_status_t) → update the Go const block. Binding-invented labels that
aren't in upstream (LABEL_COMPOSITE, LABEL_STATEFUL) must be renumbered to
sit past the last upstream value so they can't collide.
- Enum-typed param widened to a fixed-width integer (e.g.
hegel_settings_set_mode(…, hegel_mode_t mode) became (…, uint32_t mode);
same for set_backend, set_verbosity, mark_complete's status) → the Go
enum's base type must mirror the parameter's exact width and signedness
literally, even though it's ABI-transparent. Change type Mode int32 to
type Mode uint32 so it matches the uint32_t param, not the (still-signed)
C enum it descends from. This is a real ABI difference to encode even
though purego marshals a signed and an unsigned 32-bit value into the argument
register identically for the small non-negative values these enums hold — so
the integration tests pass either way and a mismatch will not be caught by
the test suite; only a literal read of the header catches it. A base-type
change needs a go generate ./internal/libhegel (§4), but the generated
_string.go is usually byte-identical (the index assertions and string tables
don't depend on signedness). Note the asymmetry: a value written through an
out-param typed as the enum pointer (hegel_run_status_t *, i.e. RunStatus)
is not widened and keeps mirroring the enum (int32).
4. Editing the binding — the things that move together
These must stay consistent or the build/coverage breaks. In practice only the
first two are hand-edited for a signature change; stub.go is reflection-driven
and adapts on its own (item 3), and the wrapper method (item 4) changes only when
the call shape does:
libhegel.go — symbols struct: one func-typed field per C symbol.
Mirror the C signature exactly: func(ctxT, <args>, <*outParam>) Error for a
fallible call. The two exceptions are the C functions that return a value
directly rather than through a result code — only hegel_context_last_error
(func(ctxT) string) currently does this.
libhegel.go — registerSymbols table in tryOpen: {"hegel_x", &syms.x}.
stub.go — reflection-driven, usually needs no edit: Stub(tb, returns ...any) builds a symbols by reflection — it walks the struct and installs a
reflect.MakeFunc closure on every func-typed field, then returns a
*Context. Because the closures are synthesized from each field's type at
runtime, adding, removing, reordering, or retyping a symbols field needs no
change to stub.go — it adapts automatically. Each synthesized
closure recognizes its out-params structurally, not by name: an out[T]
argument by reflect.Type identity, or a pointer-to-~uintptr as a produced
handle. Every non-pointer argument (a by-value scalar, a string, or the
always-NULL outputCallbackT / user_data args) is treated as an input and
consumes no scripted return value. Destructors (field name contains
Free) are detected and auto-free their handle, scripting no return. Values
are supplied by the caller via returns ...any and popped in strict call
order by a single retval() helper. So the thing that moves in lockstep with a
signature change is not stub.go but each test's returns ...any list when a
symbol's output type or call order changes (see §5) — a new input arg
that consumes no return value (like the NULL callback) needs no returns
change either.
libhegel.go — wrapper method: a thin, typed Go method that threads a
*Context. Handle constructors live on Context (e.g. SettingsNew) or on
the parent handle (e.g. Settings.RunStart, Run.NextTestCase) and go
through allocate(ctx, op, new, free). Other fallible calls go through
ctx.invoke(op, fn). Reader methods (Result.Status, Result.FailureCount,
Failure.Origin, …) use the reusable out* scratch fields on the wrapper
struct (TestCase, Result, Failure) so the hot per-draw path doesn't
allocate a fresh out-param on every call.
Regenerate the stringer output
Enum types are listed in the //go:generate directive at the top of
libhegel.go (currently
-type=Error,Status,Mode,Backend,Verbosity,RunStatus,HealthCheck,Phase,Label).
After adding an enum type or changing any enum constant value:
go generate ./internal/libhegel # uses `go tool stringer`
Changed constant values break the compile-time _ = x[CONST-N] assertions in
libhegel_string.go until you regenerate, so this is not optional. Add new enum
types to the -type= list. The generated _string.go is coverage-excluded.
5. Coverage and tests
100% per-file coverage is enforced (see the coverage skill). New or changed
wrapper methods need coverage:
- Methods the runner exercises (result/status handling, generate, spans,
mark_complete) are covered by the stub-driven tests in
runner_test.go —
update those Stub(...) return lists when a symbol's output type or call
order changes (e.g. a bool "passed" value becoming a RunStatus).
- Methods not wired into the runner (pools, state machines, blob replay,
primitive draws, the unwired settings setters) are covered by direct stub
tests in
internal/libhegel/stub_test.go. Build a *Context with
lib := Stub(...), construct the wrapper value in-package against its symbol
table —
tc := &TestCase{pointer: &pointer[testCaseT]{syms: lib.syms, raw: 1}} or
s := &Settings{syms: lib.syms, raw: 1} — and call the method, passing lib
as the *Context. Cover both the happy path and every error branch (a guard
that rejects bad input, like cStringArray's interior-NUL check, needs a test
that trips it).
- Prefer covering a method to annotating it
// coverage-ignore. The ratchet in
.github/coverage-ratchet.json auto-tightens when the annotation count drops,
so removing now-covered ignores is free and good.
6. purego pitfalls
- A symbol that returns
const char * directly → type the field
func(ctxT) string (not *byte/uintptr). Only hegel_context_last_error
does this today.
- A symbol that writes a string through a
const char ** out-param → type
that argument **byte and read it back with goString, which copies the
NUL-terminated buffer immediately (a NULL pointer — libhegel's "no string" —
yields ""). This is how hegel_version, hegel_run_result_error, and the
failure getters return strings.
- Produced handles and scalars come back through trailing out-params
(
*settingsT, *runT, *uint64, *bool, *int64, *Collection,
*RunStatus), never as the return value — the return is always the Error
code.
- Keeping Go memory alive across a call is automatic — do not thread a
keepalive return value or sprinkle runtime.KeepAlive. The GC keeps Go
memory reachable for the duration of a call as long as it is passed as a
typed pointer (*byte, **byte, *Date, …) rather than flattened to a
uintptr. A typed pointer argument is a live root through the call, and the
GC traces transitively from it (a **byte pins the pointer array and every
buffer its elements point at). Memory is lost only if you drop to uintptr —
then the GC can't see it and may free/move it mid-call. So a helper that
produces a C-facing pointer should return just (ptr, len); a separate
keepalive slice is a code smell that means someone thought a typed pointer
needs propping up. It doesn't.
const char * argument → pass a Go string; purego manages the memory.
- A length-delimited byte buffer argument (
const uint8_t * plus a size_t
length, e.g. include_characters) → build it with cString, the inverse of
goString: it aliases the Go string's bytes via unsafe.StringData (no
[]byte copy) and returns (*byte, len). A nil/empty string yields a NULL
pointer. No keepalive — the returned *byte is what keeps the string
reachable (see the bullet above).
const char *const * (array of C strings) is not handled automatically.
cStringArrayArg builds a []*byte of NUL-terminated buffers and returns
(cStringArrayPtr, len, err). The typed **byte return keeps both the
pointer array and its buffers reachable across the call — no keepalive. A Go
string may contain an interior NUL but a C string can't, so cStringArrayArg
rejects such input with an error rather than letting C silently truncate it. A
nil slice yields a NULL pointer ("absent"); a non-nil empty slice yields a
non-NULL, length-0 pointer ("the empty set"), which libhegel treats as
distinct.
- A struct passed by value (e.g.
hegel_date_t / hegel_time_t /
hegel_datetime_t as a hegel_generate_* bound) needs explicit padding
fields. purego packs struct fields into argument registers sequentially by
field size and only flushes an eightbyte at an exact 64-bit boundary — it
does not honor the struct's natural alignment padding. So a Go struct whose
C counterpart has interior or trailing padding (like hegel_time_t, where
microsecond sits at offset 4 after a 1-byte pad) must spell that padding out
as real blank fields (_ uint8, _ uint16, …) at the right offsets, or the
fields land in the wrong registers and the C side reads garbage (the classic
symptom: a drawn bound like 23:59:59 arriving as 59:63:66). Add the padding
directly to the exported struct — the same explicit layout also matches C in
memory, so one struct type serves both the by-value inputs and the pointer
output, with no separate ABI/conversion type. Blank padding fields marshal
fine (purego reads them as their zero value). Structs of two eightbytes or
fewer pass in registers; a //go:generate-free way to sanity-check a new one
is a throwaway real-lib draw asserting the bound round-trips. This affects only
by-value struct arguments; a struct written through an out-pointer
(*Date) uses the natural in-memory layout and needs no special handling
beyond the shared padded definition.
7. Verify
just check vendored # lint + check-docs + test against the vendored binary
Use vendored mode here too (see §2) — it guarantees the tests run against the
vendored binary, not a stale local build.
Confirm the version smoke test actually ran against the real lib (not skipped):
go test -run 'TestLoadLibVersion|TestLibhegelEndToEnd' -v ./internal/libhegel/
8. Validation gate — independent completeness audit
The steps above are done by the same context that made the edits, so they share
its blind spots: a symbol you never noticed in the header is a symbol you also
won't notice is unbound. Close that gap with a fresh-context audit as the
final gate. Launch a separate agent (Task tool, subagent_type: "general-purpose") that has not seen your edits and whose only job is to diff
the header against the binding and report anything missing.
Give the agent a self-contained prompt — it starts with no context, so spell
out the version, the two files to compare, and the exact output you want:
Audit the libhegel FFI binding for completeness against the C header. Do NOT
edit anything — this is a read-only verification.
1. Read the pinned version from internal/libhegel/version.go (hegelVersion).
2. Fetch the matching header:
curl -sSL https://raw.githubusercontent.com/hegeldev/hegel-rust/v<VERSION>/hegel-c/include/hegel.h
(note the `v` prefix on the tag).
3. Extract every `hegel_*` function declared in the header.
4. Cross-check each against internal/libhegel/libhegel.go: it must have (a) a
field in the `symbols` struct, (b) an entry in the `registerSymbols` table
in `tryOpen`, and (c) a wrapper method (or a documented reason it has none).
Also flag any registered symbol that is NOT in the header (a stale binding
that would break `registerSymbols` at load).
5. For every symbol, confirm the Go field signature matches the C prototype
under the context-based ABI (ctx first, Error return, produced values via
trailing out-params).
6. Check every integer parameter's width AND signedness against the C type,
including enum-typed params. A C param typed `uint32_t` (even one that
carries a semantically-enum value, e.g. `hegel_mode_t`/`hegel_status_t`
values passed through a widened `uint32_t` slot) must be bound by a
Go type whose base is `uint32`, not `int32` — flag a signed Go enum
bound to an unsigned C param as SIGNATURE-MISMATCH. This mismatch is
invisible to the runtime tests (purego marshals both identically for small
values), so this literal-read check is the only thing that catches it.
7. For every parameter that is NEW in this version (present in the header
prototype but absent from the prior binding), check the corresponding
`internal/libhegel` wrapper method: is the new arg forwarded through the
wrapper's own signature, or hardcoded to a constant inside the wrapper? A new
arg hardcoded inside the wrapper (rather than plumbed to the wrapper signature
and defaulted at the package-`hegel` caller) is a BURIED-DEFAULT — flag it.
This is a design-policy check, not an ABI failure.
Report a table of every header function with OK / MISSING / SIGNATURE-MISMATCH /
BURIED-DEFAULT, and a final verdict line. List discrepancies explicitly; do not
fix them.
The agent's report is the gate: if it comes back clean, the alignment is
complete. If it flags a MISSING or SIGNATURE-MISMATCH, return to §3–§5,
fix it, and re-run this gate. This is a genuine independent check only because
the agent rederives the header→binding mapping from scratch — do not paste your
own diff or conclusions into its prompt.
1---2name: align-libhegel3description: How to align the Go FFI wrapper to a new libhegel (hegel-c) release. Use after bumping the pinned libhegel version (internal/libhegel/version.go, via `just vendor-libhegel`), when `just check` fails with a symbol-resolution or version-mismatch error against libhegel, or whenever the hegel-c C API in hegel.h has changed and the Go bindings need to catch up.4---56# Aligning the Go wrapper to a new libhegel release78`libhegel` is a Rust cdylib built from hegel-rust's `hegel-c` crate. hegel-go9calls it via purego FFI. When the pinned version changes, the C API in10`hegel-c/include/hegel.h` may have added, removed, renamed, or re-typed symbols,11and the low-level wrapper in `internal/libhegel/` must be re-aligned. The main12`hegel` package's exported API must **not** change as a result — only the13internal binding layer.1415**Within that binding layer, the wrapper API *should* track the C API.** When a16C function gains a parameter, plumb it through the `internal/libhegel` wrapper17method (`Settings.RunStart`, …) rather than hardcoding a constant inside the18wrapper — the wrapper's exported signature is meant to mirror the C signature.19Any behavior-preserving default for a new *optional* parameter (a NULL callback,20a nullable pointer, a `0`-means-default flag) belongs in the **package-`hegel`21callers** (`runner.go`, …), which pass `0`/NULL — not baked into the wrapper.22This keeps the binding honest (a future caller *can* supply a real value) while23holding the `hegel` package's public API stable. Example: `hegel_output_callback_t`24added `(callback, user_data)` to `hegel_run_start`; the wrapper takes both and25the runner passes `0, 0`.2627## The context-based ABI2829Every fallible libhegel call follows one convention, and the whole wrapper is30shaped around it:3132- The **first argument** is a `hegel_context_t` (an error-reporting context).33- The **return value** is an `Error` result code (`OK` is 0; failures are34 negative — `E_INVALID_HANDLE`, `E_INVALID_ARG`, …).35- Any **value the call produces** (a handle, a count, a bool, a byte buffer) is36 written through a **trailing out-parameter**, not returned.37- On a non-`OK` return, the human-readable message is read back from the context38 via `hegel_context_last_error`.3940So `symbols` (the loaded library) is immutable and shared across goroutines,41while the per-call error state lives on a `*Context`. The wrapper threads a42`*Context` through every method and funnels every call through two helpers:4344- `ctx.invoke(op, fn)` — runs `fn(ctxT)`, and if it returns non-`OK`, reads45 `hegel_context_last_error` and wraps it into a Go `error`.46- `allocate(ctx, op, new, free)` — `invoke` for handle constructors: maps a47 zero out-handle to a `nil` result (the engine's "no object" sentinel, e.g. a48 finished run) and registers a GC cleanup that calls `free`.4950## 1. Find the pinned version and fetch the matching header5152The version lives in `internal/libhegel/version.go` as `hegelVersion` (bump it53with `just vendor-libhegel <version>`, which also refreshes the vendored54binaries under `internal/libhegel/libs/`).5556The release **tag is `v<VERSION>`** (note the `v` prefix — the raw path without57it 404s):5859```bash60curl -sSL https://raw.githubusercontent.com/hegeldev/hegel-rust/v<VERSION>/hegel-c/include/hegel.h61```6263## 2. Get the matching library — always `just check vendored`6465`just check` runs integration tests (`TestLoadLibVersion`, `TestLibhegelEndToEnd`)66against the **real** library, and `TestLoadLibVersion` asserts the loaded lib67reports exactly `hegelVersion`. You need a libhegel at that exact version, and68the simplest way to get it is to let the loader use the vendored binary:6970```bash71just check vendored72```7374**Always use `vendored` mode for this work — never a local build.** The75`vendored` mode leaves `HEGEL_LIBHEGEL_PATH` unset, so the loader materializes76the `go:embed`'d vendored binary for `hegelVersion` (under77`internal/libhegel/libs/`, refreshed by `just vendor-libhegel`) into the cache.78A plain `just check` would instead build whatever the sibling `../hegel-rust`79checkout happens to be on (likely an *older* tag, since you've just bumped the80version), point `HEGEL_LIBHEGEL_PATH` at that stale `.so`, and fail the version81test. Don't touch the checkout; the vendored binary is always the right version.8283The cached library lands at:8485```bash86${XDG_CACHE_HOME:-$HOME/.cache}/hegel-go/libhegel/<VERSION>/libhegel-linux-amd64.so87```8889**When you need the symbol table**, run `nm -D` against that cached `.so` — no90build required. Resolve the path with a glob so you don't have to spell out the91version:9293```bash94LIB=$(ls "${XDG_CACHE_HOME:-$HOME/.cache}"/hegel-go/libhegel/*/libhegel-linux-amd64.so | tail -1)95nm -D "$LIB" | grep '^.* T hegel_' | sort96```9798The `nm` output is ground truth for which `hegel_*` symbols exist — diff it99against the registration table in `tryOpen` (`libhegel.go`). A symbol the wrapper100registers but the lib no longer exports makes `registerSymbols` fail and101**every** integration test dies at load.102103## 3. Compare hegel.h against `internal/libhegel/libhegel.go`104105Walk every C declaration and check it against the wrapper. Categorize each:106107- **Removed symbol** → delete its `symbols` field, its registration-table entry,108 its `stub.go` closure, and any wrapper method. Re-route callers.109- **Renamed/retyped symbol** (e.g. `hegel_run_result_passed`, a `bool`, became110 `hegel_run_result_status`, a `hegel_run_status_t` written through an out-param)111 → update the field signature, the registration name, the wrapper method, and112 the `stub.go` closure's signature/out-param handling.113- **New symbol** → add a `symbols` field, a registration entry, a wrapper method,114 a `stub.go` closure, and a test (see §5).115- **Changed signature** (a new arg, or a value that moved from return to116 out-param) → remember the ABI convention: ctx first, `Error` return,117 produced value through a trailing out-pointer. The `symbols` field type and the118 wrapper method change together (`stub.go` adapts on its own — see §4.3). A new119 arg must be **plumbed through the wrapper method's signature**, not hardcoded120 inside the wrapper. If it is an *optional* arg with a behavior-preserving121 default (a NULL callback, a nullable pointer, a `0`-means-default flag), leave122 the wrapper faithful and pass the default from the **package-`hegel` callers**123 (see the charter note in §intro). Do not silently bury a default in the124 wrapper — that hides the new capability and the §8 audit cannot tell a125 deliberate default from an oversight.126- **Changed enum/constant values** (e.g. a new `HEGEL_LABEL_*`, or a reordered127 `hegel_status_t`) → update the Go `const` block. Binding-invented labels that128 aren't in upstream (`LABEL_COMPOSITE`, `LABEL_STATEFUL`) must be renumbered to129 sit *past* the last upstream value so they can't collide.130- **Enum-typed param widened to a fixed-width integer** (e.g.131 `hegel_settings_set_mode(…, hegel_mode_t mode)` became `(…, uint32_t mode)`;132 same for `set_backend`, `set_verbosity`, `mark_complete`'s `status`) → the Go133 enum's **base type must mirror the parameter's exact width and signedness134 literally**, even though it's ABI-transparent. Change `type Mode int32` to135 `type Mode uint32` so it matches the `uint32_t` param, not the (still-signed)136 C `enum` it descends from. This is a real ABI difference to encode even137 though purego marshals a signed and an unsigned 32-bit value into the argument138 register identically for the small non-negative values these enums hold — so139 the integration tests pass either way and a mismatch will **not** be caught by140 the test suite; only a literal read of the header catches it. A base-type141 change needs a `go generate ./internal/libhegel` (§4), but the generated142 `_string.go` is usually byte-identical (the index assertions and string tables143 don't depend on signedness). Note the asymmetry: a value written through an144 out-param typed as the enum pointer (`hegel_run_status_t *`, i.e. `RunStatus`)145 is *not* widened and keeps mirroring the enum (`int32`).146147## 4. Editing the binding — the things that move together148149These must stay consistent or the build/coverage breaks. In practice only the150first two are hand-edited for a signature change; `stub.go` is reflection-driven151and adapts on its own (item 3), and the wrapper method (item 4) changes only when152the call shape does:1531541. **`libhegel.go` — `symbols` struct**: one `func`-typed field per C symbol.155 Mirror the C signature exactly: `func(ctxT, <args>, <*outParam>) Error` for a156 fallible call. The two exceptions are the C functions that return a value157 directly rather than through a result code — only `hegel_context_last_error`158 (`func(ctxT) string`) currently does this.1592. **`libhegel.go` — `registerSymbols` table** in `tryOpen`: `{"hegel_x", &syms.x}`.1603. **`stub.go` — reflection-driven, usually needs no edit**: `Stub(tb, returns161 ...any)` builds a `symbols` by reflection — it walks the struct and installs a162 `reflect.MakeFunc` closure on every `func`-typed field, then returns a163 `*Context`. Because the closures are synthesized from each field's type at164 runtime, **adding, removing, reordering, or retyping a `symbols` field needs no165 change to `stub.go`** — it adapts automatically. Each synthesized166 closure recognizes its out-params **structurally, not by name**: an `out[T]`167 argument by `reflect.Type` identity, or a pointer-to-`~uintptr` as a produced168 handle. Every non-pointer argument (a by-value scalar, a `string`, or the169 always-NULL `outputCallbackT` / `user_data` args) is treated as an input and170 consumes **no** scripted return value. Destructors (field name contains171 `Free`) are detected and auto-free their handle, scripting no return. Values172 are supplied by the caller via `returns ...any` and popped in strict call173 order by a single `retval()` helper. So the thing that moves in lockstep with a174 signature change is not `stub.go` but each test's `returns ...any` list when a175 symbol's **output** type or call order changes (see §5) — a new *input* arg176 that consumes no return value (like the NULL callback) needs no `returns`177 change either.1784. **`libhegel.go` — wrapper method**: a thin, typed Go method that threads a179 `*Context`. Handle constructors live on `Context` (e.g. `SettingsNew`) or on180 the parent handle (e.g. `Settings.RunStart`, `Run.NextTestCase`) and go181 through `allocate(ctx, op, new, free)`. Other fallible calls go through182 `ctx.invoke(op, fn)`. Reader methods (`Result.Status`, `Result.FailureCount`,183 `Failure.Origin`, …) use the reusable `out*` scratch fields on the wrapper184 struct (`TestCase`, `Result`, `Failure`) so the hot per-draw path doesn't185 allocate a fresh out-param on every call.186187### Regenerate the stringer output188189Enum types are listed in the `//go:generate` directive at the top of190`libhegel.go` (currently191`-type=Error,Status,Mode,Backend,Verbosity,RunStatus,HealthCheck,Phase,Label`).192After adding an enum type or changing any enum constant value:193194```bash195go generate ./internal/libhegel # uses `go tool stringer`196```197198Changed constant values break the compile-time `_ = x[CONST-N]` assertions in199`libhegel_string.go` until you regenerate, so this is not optional. Add new enum200types to the `-type=` list. The generated `_string.go` is coverage-excluded.201202## 5. Coverage and tests203204100% per-file coverage is enforced (see the `coverage` skill). New or changed205wrapper methods need coverage:206207- Methods the runner exercises (result/status handling, generate, spans,208 mark_complete) are covered by the stub-driven tests in `runner_test.go` —209 update those `Stub(...)` return lists when a symbol's output type or call210 order changes (e.g. a `bool` "passed" value becoming a `RunStatus`).211- Methods **not** wired into the runner (pools, state machines, blob replay,212 primitive draws, the unwired settings setters) are covered by direct stub213 tests in `internal/libhegel/stub_test.go`. Build a `*Context` with214 `lib := Stub(...)`, construct the wrapper value in-package against its symbol215 table —216 `tc := &TestCase{pointer: &pointer[testCaseT]{syms: lib.syms, raw: 1}}` or217 `s := &Settings{syms: lib.syms, raw: 1}` — and call the method, passing `lib`218 as the `*Context`. Cover both the happy path and every error branch (a guard219 that rejects bad input, like `cStringArray`'s interior-NUL check, needs a test220 that trips it).221- Prefer covering a method to annotating it `// coverage-ignore`. The ratchet in222 `.github/coverage-ratchet.json` auto-tightens when the annotation count drops,223 so removing now-covered ignores is free and good.224225## 6. purego pitfalls226227- A symbol that **returns** `const char *` directly → type the field228 `func(ctxT) string` (not `*byte`/`uintptr`). Only `hegel_context_last_error`229 does this today.230- A symbol that **writes** a string through a `const char **` out-param → type231 that argument `**byte` and read it back with `goString`, which copies the232 NUL-terminated buffer immediately (a NULL pointer — libhegel's "no string" —233 yields `""`). This is how `hegel_version`, `hegel_run_result_error`, and the234 failure getters return strings.235- Produced **handles and scalars** come back through trailing out-params236 (`*settingsT`, `*runT`, `*uint64`, `*bool`, `*int64`, `*Collection`,237 `*RunStatus`), never as the return value — the return is always the `Error`238 code.239- **Keeping Go memory alive across a call is automatic — do not thread a240 `keepalive` return value or sprinkle `runtime.KeepAlive`.** The GC keeps Go241 memory reachable for the duration of a call as long as it is passed as a242 *typed pointer* (`*byte`, `**byte`, `*Date`, …) rather than flattened to a243 `uintptr`. A typed pointer argument is a live root through the call, and the244 GC traces transitively from it (a `**byte` pins the pointer array *and* every245 buffer its elements point at). Memory is lost only if you drop to `uintptr` —246 then the GC can't see it and may free/move it mid-call. So a helper that247 produces a C-facing pointer should return just `(ptr, len)`; a separate248 `keepalive` slice is a code smell that means someone thought a typed pointer249 needs propping up. It doesn't.250- `const char *` **argument** → pass a Go `string`; purego manages the memory.251- A **length-delimited byte buffer** argument (`const uint8_t *` plus a `size_t`252 length, e.g. `include_characters`) → build it with `cString`, the inverse of253 `goString`: it aliases the Go string's bytes via `unsafe.StringData` (no254 `[]byte` copy) and returns `(*byte, len)`. A nil/empty string yields a NULL255 pointer. No keepalive — the returned `*byte` is what keeps the string256 reachable (see the bullet above).257- `const char *const *` (array of C strings) is **not** handled automatically.258 `cStringArrayArg` builds a `[]*byte` of NUL-terminated buffers and returns259 `(cStringArrayPtr, len, err)`. The typed `**byte` return keeps both the260 pointer array and its buffers reachable across the call — no keepalive. A Go261 string may contain an interior NUL but a C string can't, so `cStringArrayArg`262 rejects such input with an error rather than letting C silently truncate it. A263 nil slice yields a NULL pointer ("absent"); a non-nil empty slice yields a264 non-NULL, length-0 pointer ("the empty set"), which libhegel treats as265 distinct.266- A **struct passed by value** (e.g. `hegel_date_t` / `hegel_time_t` /267 `hegel_datetime_t` as a `hegel_generate_*` bound) needs **explicit padding268 fields**. purego packs struct fields into argument registers sequentially by269 field size and only flushes an eightbyte at an exact 64-bit boundary — it270 does *not* honor the struct's natural alignment padding. So a Go struct whose271 C counterpart has interior or trailing padding (like `hegel_time_t`, where272 `microsecond` sits at offset 4 after a 1-byte pad) must spell that padding out273 as real blank fields (`_ uint8`, `_ uint16`, …) at the right offsets, or the274 fields land in the wrong registers and the C side reads garbage (the classic275 symptom: a drawn bound like `23:59:59` arriving as `59:63:66`). Add the padding276 directly to the exported struct — the same explicit layout also matches C in277 memory, so one struct type serves both the by-value inputs and the pointer278 output, with no separate ABI/conversion type. Blank padding fields marshal279 fine (purego reads them as their zero value). Structs of two eightbytes or280 fewer pass in registers; a `//go:generate`-free way to sanity-check a new one281 is a throwaway real-lib draw asserting the bound round-trips. This affects only282 by-**value** struct arguments; a struct written through an out-pointer283 (`*Date`) uses the natural in-memory layout and needs no special handling284 beyond the shared padded definition.285286## 7. Verify287288```bash289just check vendored # lint + check-docs + test against the vendored binary290```291292Use `vendored` mode here too (see §2) — it guarantees the tests run against the293vendored binary, not a stale local build.294295Confirm the version smoke test actually ran against the real lib (not skipped):296297```bash298go test -run 'TestLoadLibVersion|TestLibhegelEndToEnd' -v ./internal/libhegel/299```300301## 8. Validation gate — independent completeness audit302303The steps above are done by the same context that made the edits, so they share304its blind spots: a symbol you never noticed in the header is a symbol you also305won't notice is unbound. Close that gap with a **fresh-context audit** as the306final gate. Launch a separate agent (Task tool, `subagent_type:307"general-purpose"`) that has *not* seen your edits and whose only job is to diff308the header against the binding and report anything missing.309310Give the agent a self-contained prompt — it starts with no context, so spell311out the version, the two files to compare, and the exact output you want:312313```314Audit the libhegel FFI binding for completeness against the C header. Do NOT315edit anything — this is a read-only verification.3163171. Read the pinned version from internal/libhegel/version.go (hegelVersion).3182. Fetch the matching header:319 curl -sSL https://raw.githubusercontent.com/hegeldev/hegel-rust/v<VERSION>/hegel-c/include/hegel.h320 (note the `v` prefix on the tag).3213. Extract every `hegel_*` function declared in the header.3224. Cross-check each against internal/libhegel/libhegel.go: it must have (a) a323 field in the `symbols` struct, (b) an entry in the `registerSymbols` table324 in `tryOpen`, and (c) a wrapper method (or a documented reason it has none).325 Also flag any registered symbol that is NOT in the header (a stale binding326 that would break `registerSymbols` at load).3275. For every symbol, confirm the Go field signature matches the C prototype328 under the context-based ABI (ctx first, Error return, produced values via329 trailing out-params).3306. Check every integer parameter's width AND signedness against the C type,331 including enum-typed params. A C param typed `uint32_t` (even one that332 carries a semantically-enum value, e.g. `hegel_mode_t`/`hegel_status_t`333 values passed through a widened `uint32_t` slot) must be bound by a334 Go type whose base is `uint32`, not `int32` — flag a signed Go enum335 bound to an unsigned C param as SIGNATURE-MISMATCH. This mismatch is336 invisible to the runtime tests (purego marshals both identically for small337 values), so this literal-read check is the only thing that catches it.3387. For every parameter that is NEW in this version (present in the header339 prototype but absent from the prior binding), check the corresponding340 `internal/libhegel` wrapper method: is the new arg forwarded through the341 wrapper's own signature, or hardcoded to a constant inside the wrapper? A new342 arg hardcoded inside the wrapper (rather than plumbed to the wrapper signature343 and defaulted at the package-`hegel` caller) is a BURIED-DEFAULT — flag it.344 This is a design-policy check, not an ABI failure.345346Report a table of every header function with OK / MISSING / SIGNATURE-MISMATCH /347BURIED-DEFAULT, and a final verdict line. List discrepancies explicitly; do not348fix them.349```350351The agent's report is the gate: if it comes back clean, the alignment is352complete. If it flags a `MISSING` or `SIGNATURE-MISMATCH`, return to §3–§5,353fix it, and re-run this gate. This is a genuine independent check only because354the agent rederives the header→binding mapping from scratch — do not paste your355own diff or conclusions into its prompt.