Contents

lace-ffi ABI

lace-ffi is the shared binding layer for Rust-owned Lace sans-IO machines and values. Thin C/WASM exports and the PyO3 binding share safe typed Rust APIs; raw trusted-record byte commits are an explicitly unsafe subset described below. Hosts own stores, raw byte IO, waits, scheduling, cancellation, and lifecycle.

ABI version 35 exposes records, record stream decoding, canonical policy execution, optional policy authoring, private runtime ILTP framing, interlace polling, the retention sweep, and move-only StoreWork. Hosts require this exact lace_ffi_version() before other ABI calls; there is no alternate-version surface. Connected-carrier construction receives no transport endpoint or generic host authority.

Rust generators emit checked-in JavaScript numeric values, and cbindgen emits the C header. Declaration hashes, generated values, C policy calls, and the exact raw-WASM export allowlist are checked independently.

Ownership

The ABI exposes opaque Rust-owned parts, owned values, borrowed views, explicit destructors, caller-owned diagnostic output, typed polls, and typed errors. There is no context, runtime, handle table, store registry, executor, scheduler, reactor, worker, callback table, route map, socket, or public event queue.

Rust owns:

Hosts own Lace values and handles, stores, mutation publication, raw duplex or split byte IO, timers, waits, scheduling, cancellation, operations, public outcomes, and lifecycle. Each operation maps an interlace side to its retained Lace handle; no store identity crosses the ABI.

Opaque parts are single-thread-affine unless documented otherwise. LaceStoreWork is move-only and may cross one foreign thread or await, but is never accessed concurrently. Destructors free Rust memory only and do not close host IO or schedule cleanup.

Pointer and slice safety

Every public Rust extern "C" function that dereferences caller-supplied raw pointers is unsafe and links to this contract. Each unsafe call requires:

Violating pointer provenance is undefined behavior. No global last-error buffer exists. Constructors and integration failures may fill caller-owned LaceErrorOut; terminal machine failures remain typed fields in the retained poll. Interlace and ILTP entry points always write a category: core and machine failures keep their typed category (an oversized operand is LimitExceeded, replacement of a closed interlace is Policy), decoder failures are PeerProtocol except local stream-decoder failures, and argument misuse and local encoding failures are Integration. A diagnostic output is optional and reusable; read its message/category only after a failed call, never as a last-call success flag. Success need not clear an earlier diagnostic. Borrowed machine/value slices remain valid until the next mutating owner call. WASM callers reacquire memory.buffer after reservations that may grow memory.

Trusted record bytes

lace_store_memory_builder_add_record and the StoreWork lace_store_work_result_reserve_entry_bytes/lace_store_work_result_commit_entry_bytes pair are trusted-record transport, not record-import APIs. Their bytes must be one complete canonical, fully validated record, unchanged since validation or read from trusted committed storage. This includes valid UTF-8, structure, outer and embedded hashes, and Seal Mark evidence. The caller fills the complete reservation before commit, does not mutate it concurrently, and does not use its pointer after commit, submission, or work free. Cheap glyph and request/hash checks do not establish these preconditions; violating them can invalidate unchecked record views, not merely produce a typed error. Untrusted bytes enter through lace_record_parse before use on these paths.

The Rust StoreWork::commit_entry_bytes method is likewise unsafe: its callers establish the same validated-byte and reservation-lifetime preconditions. StoreWork::add_entry_record accepts a validated Record owner and remains safe; PyO3 uses it and set_result instead of raw-byte commits.

Generated binding surfaces

A safe Rust use by PyO3 does not retain a C symbol. The C surface omits duplicate slice-return helpers, copy conveniences, and diagnostic-only values.

Shared binding layer

The rlib owns:

It owns no runtime policy, host store, worker, future, socket, or callback.

ABI values

Safe Rust enums define numeric values for protocol side, participant position, carrier role, interlace poll/effect, observation, close reason, error category, timeout phase, StoreOp kind, query constraint kind, store error, and peer-proof assurance. Foreign construction accepts Disabled (0), ConfidentialChannel (1), and ChannelBound (2), using ILTP 0.13 and lace-iltp-peer-proof-v3 proofs. The host establishes its channel before adoption and supplies any opaque 32-byte binding. Lace supplies no encryption or transport handshake.

C and generated JavaScript values come from these enums. The C header explicitly includes LacePeerProofAssurance and LaceStoreErrorKind constants even where functions accept integer tags; unknown integer inputs remain rejectable. Result construction uses typed functions, not a separate result-kind tag enum. PyO3 maps the shared enums directly to Python values and carries no numeric copy. Unknown machine enum inputs are integration errors. Hosts never infer categories from diagnostic text.

Optional source compiler

Cargo feature compiler enables the optional lace-compiler dependency and one source entry point:

LacePolicy *lace_policy_compile(
    const uint8_t *source, size_t length, size_t max_source_bytes,
    LaceErrorOut *error);

The feature is off by default. C consumers define LACE_FFI_FEATURE_COMPILER only when linking a compiler-enabled library. There are no disabled stubs. Standard JavaScript WASM builds explicitly enable it; compiler-free builds use the same canonical execution and composition ABI. Python uses its direct safe compiler dependency, not the C entry point.

Compilation is one synchronous call over complete UTF-8 source borrowed for the call. It accepts anonymous Interlang with local helpers and datalog: blocks, not module declarations, imports, finite targets, or unresolved references. Empty and helper-only source cannot produce an exchange policy. Success returns one independently owned LacePolicy, freed by lace_policy_free. Source bytes are not retained; there is no compiler owner, slot, descriptor, or replay owner.

max_source_bytes is an explicit complete-source byte bound; pass 1048576 for the standard default. Zero is a real bound. Fixed exchange rule/arity/TopK bounds are enforced by shared Rust preparation for compiled and canonical inputs, not ABI options. Rust enforces source accounting before lowering; JavaScript does not implement a second compiler or parser.

Failure returns null and overwrites both message and category in LaceErrorOut: preparation limits are LimitExceeded; malformed source and argument errors are Policy. Read the category only after failure, not as a last-call success flag.

All text is pointer/byte-length UTF-8, not NUL-terminated. A null source pointer is allowed only for zero length. Composition pointer arrays require aligned storage for their complete lengths. lace_alloc guarantees only byte alignment; JavaScript stages one padded allocation with an aligned interior pointer, then frees the original pointer and length in finally. It reacquires memory.buffer after allocating calls and never retains a WASM view across a call that can grow memory.

Canonical policy and records (always available)

Canonical execution uses lace_policy_from_canonical_text, which validates the ordinary 030/040 bytes and rejects noncanonical text, unresolved source syntax, and helper-only operands before operation-owned constraints are added. lace_policy_compose(authoring, authoring_count, guests, guest_count, exclusions, exclusion_count, error) borrows one aligned array of borrowed const LacePolicy * values per Lace-130 class and one aligned array of LaceFieldExclusion values, and invokes the core n-ary facet-constraint merger once with all three. Each exclusion component is a borrowed UTF-8 pointer and length; a null pointer is the anonymous _. It returns one new owned policy, without calling the authoring compiler. Inputs remain live and unchanged. Null arrays are permitted only for zero count; empty authoring input, null entries and non-UTF-8 or invalid exclusion text are policy errors. Empty guest and exclusion arrays leave the output identical to an authoring-only composition. Every input has at least one facet; helper-only programs are rejected before operation-owned constraints can make them executable. Veto-only inputs are valid. Input order and exact canonical duplicates within a class do not affect output bytes. Nested calls need not have the same bytes or limit behavior as a single n-ary call.

lace_policy_all_records(), lace_policy_tx_disabled(), and lace_policy_rx_disabled() each return a new owned copy of the core’s constant all-records, transmission-veto, and reception-veto policy. Porcelain hosts compose their private operands from these with lace_policy_compose rather than parsing policy text.

Failed canonical construction and composition overwrite both diagnostic message and category from the core’s typed PreparationError: exceeding fixed rule, arity or static TopK bounds is LimitExceeded; malformed, noncanonical, helper-only or otherwise invalid policy input is Policy. Other unclassified diagnostic writes reset the category to None. Read it only after failure.

Safe Rust clients use lace_compiler::compile and the core’s prepare_facet_constraints directly. InterlaceModule::from_text and from_rules preserve PreparationError; rules construction derives canonical text and identity. The shared safe binding layer exposes LacePolicy::from_module(InterlaceModule) -> Result<Self, PreparationError>, module(&self), and strict from_canonical_text(&str) -> Result<Self, PreparationError>. PyO3 wraps these Rust APIs directly and preserves their categories; JavaScript projects the FFI category rather than parsing diagnostic text. This local preparation distinction does not reclassify invalid incoming operands: peer validation remains PeerProtocol.

Plan compilation receives exactly two ordered modules and no third policy input. The C direct constructor borrows two canonical LacePolicy values and limits and uses empty final session authorities. Safe Rust direct composition can supply explicit final authority through its DirectConfig. Connected construction receives one local module or port selection, carrier role, optional By-secret, resolved proof assurance, optional stream encodings, optional expected peer By, optional port, fresh 32-byte nonce seed, optional operand cache, and limits. It receives no transport, endpoint, Lace, or store argument. Bound assurance includes the established channel’s opaque binding.

lace_interlace_machine_new_connected_carrier takes these arguments in order:

const LacePolicy *policy,
uint32_t role,
uint32_t has_by_secret, const uint8_t *by_secret_ptr, size_t by_secret_len,
uint32_t peer_proof_mode, const uint8_t *binding_ptr, size_t binding_len,
uint32_t has_stream_encodings, const uint8_t *stream_encodings_ptr,
size_t stream_encodings_len,
uint32_t has_expected_peer_by, const uint8_t *expected_peer_by_ptr,
size_t expected_peer_by_len,
uint32_t has_port, const uint8_t *port_ptr, size_t port_len,
const uint8_t *nonce_seed_ptr, size_t nonce_seed_len,
const LaceOperandCache *operand_cache,
const LaceInterlaceResourceLimits *limits, LaceErrorOut *error

A null policy constructs a port-selecting carrier: it owns no operand until the host answers each logical session’s SelectOperand poll, and takes no expected peer By at construction, so has_expected_peer_by must be 0.

A host Lace owns one LaceOperandCache (lace_operand_cache_new(entries), entries 0 holding none; lace_operand_cache_free) and passes it to every connected carrier it runs, which share it and outlive a freed handle. A null cache gives the carrier its own 32-entry cache, so its re-prefaces still hit.

ChannelBound requires exactly 32 binding bytes and a local By-secret. The constructor copies the binding; caller storage need not outlive the call. Other modes require a null binding pointer and zero length. Invalid assurance configuration fails before protocol work. The host owns transport establishment and its timeout; encryption failures are transport failures.

has_stream_encodings is exactly 0 (absent; every compiled-in encoding) or 1 (present). Present text is UTF-8 tokens separated by , in preference order; empty text offers identity only. Construction applies Lace-100’s stream-encoding rules: an unknown or uncompiled token, identity, or a repeat fails as Integration. lace_stream_encodings_validate(text_ptr, text_len, error) applies the same parse alone, so a host can reject a list when it constructs its connected peer. Cargo features deflate and zstd, on by default, compile the deflate12c and zstd20c codecs; they change no declaration.

has_expected_peer_by is exactly 0 (absent; text ignored) or 1 (present). Present text must parse as canonical V.<b64a>.H3; empty text is invalid rather than absent. Rust construction applies Lace-100’s expected-peer requirements. Failure returns null with the configuration diagnostic before protocol work; the host remains responsible for its accepted IO.

has_port is exactly 0 (absent; no Port line) or 1 (present). Present text must be a valid Lace-060 port; invalid text fails as Integration before protocol work. The carrier sends it in every preface. lace_port_validate(text_ptr, text_len, error) applies the same parse alone, so a host can reject a port when it constructs its connected peer.

The carrier retains the expectation across replacement. Rust establishment owns Lace-050’s proof checks; foreign hosts consume typed failures rather than inspect proofs or repeat checks.

lace_by_parse validates canonical public V.<b64a>.H3 text and returns that same canonical text. The record builder accepts data, ordered application fields, and optional By-secret. lace_record_builder_build returns an independently owned LaceRecord *, or null with diagnostic output on failure. Free the result with lace_record_free. Builder reuse, failure, and destruction do not invalidate prior results; the builder retains inputs only, not output bytes. Construction moves the validated Rust Record into the existing FFI record owner without serializing or reparsing it. Native Seal construction uses Rust entropy. Raw WASM uses explicit auxiliary randomness. lace_record_parse remains the validating external-byte boundary. Both paths expose hash, kind, optional By/Mark, every application-field occurrence, every valid record link, and the record’s bytes:

size_t lace_record_canonical_len(const LaceRecord *record);
size_t lace_record_data_offset(const LaceRecord *record);
size_t lace_record_data_len(const LaceRecord *record);
const uint8_t *lace_record_contiguous_ptr(const LaceRecord *record);
int32_t lace_record_read_at(const LaceRecord *record, size_t offset, uint8_t *buffer,
                            size_t capacity, size_t *read, LaceErrorOut *error);

The data occupies [data_offset, canonical_len). contiguous_ptr designates all canonical bytes where the record holds them contiguously (constructed, parsed, in-memory and mapped records) and is null otherwise; no accessor promises a complete slice. read_at copies at most capacity bytes starting at offset and writes the count, fewer only at the record’s end; it returns LACE_FFI_STEP_IDLE, or LACE_FFI_STEP_ERROR with Integration for invalid arguments or an offset past the record and Store when the source cannot be read. Record slices remain valid until that record is freed.

Record streams

A host reading a Lace-010 record stream pushes its bytes through one LaceRecordStreamDecoder, which validates each record as it passes and receives it into memory, as the host’s ILTP responses are:

LaceRecordStreamDecoder *lace_record_stream_decoder_new(uint64_t max_bytes);
void lace_record_stream_decoder_free(LaceRecordStreamDecoder *decoder);
int32_t lace_record_stream_decoder_push(LaceRecordStreamDecoder *decoder, const uint8_t *bytes,
                                        size_t len, LaceErrorOut *error);
LaceRecord *lace_record_stream_decoder_take(LaceRecordStreamDecoder *decoder);
int32_t lace_record_stream_decoder_finish(LaceRecordStreamDecoder *decoder, LaceErrorOut *error);

max_bytes bounds the canonical bytes of the whole stream (UINT64_MAX for none). push consumes input until one record completes and returns the consumed count, at most INT32_MAX; while a completed record waits it consumes nothing. take returns that record as a new owned LaceRecord *, or null. The host pushes the unconsumed suffix again after taking. finish at EOF returns LACE_FFI_STEP_IDLE. Failures return LACE_FFI_STEP_ERROR with a typed category and end the stream, discarding the partial record: a record longer than the stream’s remaining bound is LimitExceeded at its start, before its bytes are held; invalid bytes, or a stream ending inside a record, are PeerProtocol. PyO3 wraps the same safe Rust decoder.

Interlace polling

One LaceInterlaceMachine owns either a direct pair or connected carrier. A poll retains exactly one typed value:

Effects { side0, side1 }
Observation
EnteredFixedPoint
NeedPeerInput
NeedStoreAdvance
Closed
Failed
SelectOperand

Each side independently reports no effect, StoreCall, or PeerOutput. One side’s store call never hides the other. Connected peer output remains machine-owned until the host accepts it into its codec.

Poll values remain readable until machine mutation. A retained observation exposes its kind, side, and record hash; for RecordSaved, lace_interlace_machine_observation_record also returns a new owned LaceRecord * for the admitted record (null for any other poll), freed with lace_record_free. Each call returns an independent owner sharing the Arc-backed record; porcelain hosts read it at poll time instead of reloading the record from their private store. Fixed-point wake boundaries carry side, StorePosition, machine id, and fixed-point id. Per-side phase generations tell the host when to create one absolute deadline; partial IO and unrelated progress do not reset it. EOF is submitted only after decoder finalization.

A port-selecting carrier reports SelectOperand once it holds a logical session’s peer preface, before announcing its operand, and repeats it until answered:

int32_t lace_interlace_machine_select_operand(LaceInterlaceMachine *machine, const LacePolicy *policy,
                                              uint32_t has_expected_peer_by,
                                              const uint8_t *expected_peer_by_ptr,
                                              size_t expected_peer_by_len, LaceErrorOut *error);
const uint8_t *lace_interlace_machine_peer_port_ptr(const LaceInterlaceMachine *machine);
size_t lace_interlace_machine_peer_port_len(const LaceInterlaceMachine *machine);
const uint8_t *lace_interlace_machine_peer_by_ptr(const LaceInterlaceMachine *machine);
size_t lace_interlace_machine_peer_by_len(const LaceInterlaceMachine *machine);
const uint8_t *lace_interlace_machine_outgoing_encoding_ptr(const LaceInterlaceMachine *machine);
size_t lace_interlace_machine_outgoing_encoding_len(const LaceInterlaceMachine *machine);
const uint8_t *lace_interlace_machine_incoming_encoding_ptr(const LaceInterlaceMachine *machine);
size_t lace_interlace_machine_incoming_encoding_len(const LaceInterlaceMachine *machine);

select_operand borrows policy as that session’s operand, with an optional expected peer By under the constructor’s flag rules, or refuses the port with a null policy and no expectation. It returns LACE_FFI_STEP_IDLE, or LACE_FFI_STEP_ERROR: a refusal fails the carrier as PeerProtocol (port refused), and a selection construction would reject (an expectation without proof assurance, an oversized operand) fails it with that category; both are also the retained Failed poll. Argument misuse and answering when no selection is pending are Integration and leave the machine unchanged. Replacing a port-selecting carrier’s policy is Policy. The peer-port accessor borrows the port of the latest peer preface until the next mutating machine call: null with length zero when that preface named none, before any peer preface, and for a direct pair. During SelectOperand it is the port being selected for. The peer-By accessor likewise borrows the canonical By text the live peer proved in the latest logical session under confidential or channel-bound assurance: null with length zero before that proof validates, under disabled assurance, after an empty proof, from a later preface cut until the next proof validates, and for a direct pair. It is a diagnostic, never a policy fact or authority. The encoding accessors return the static 065 token of the local-to-peer and peer-to-local encoding the latest preface pair selected: null with length zero before the first peer preface and for a direct pair. Each peer preface selects them again. They are diagnostics.

Retention sweep

One LaceRetentionSweep owns a sans-IO retention sweep (store-compaction.md). It evaluates policies only; the host executes its store operations through ordinary StoreWork against the root’s exclusively owned store and hands the resulting kept set to that store’s physical compaction.

LaceRetentionSweep *lace_retention_sweep_new(const LacePolicy *const *policies, size_t count,
                                             LaceErrorOut *error);
void lace_retention_sweep_free(LaceRetentionSweep *sweep);
int32_t lace_retention_sweep_poll(LaceRetentionSweep *sweep);
LaceStoreWork *lace_retention_sweep_issue_store_work(LaceRetentionSweep *sweep, LaceErrorOut *error);
uint32_t lace_retention_sweep_store_work_is_current(const LaceRetentionSweep *sweep,
                                                    const LaceStoreWork *work);
int32_t lace_retention_sweep_submit_store_work(LaceRetentionSweep *sweep, LaceStoreWork *work,
                                               LaceErrorOut *error); /* consumes work */
uint64_t lace_retention_sweep_boundary(const LaceRetentionSweep *sweep);
uint64_t lace_retention_sweep_retained_count(const LaceRetentionSweep *sweep);
const uint8_t *lace_retention_sweep_bitmap_ptr(const LaceRetentionSweep *sweep);
size_t lace_retention_sweep_bitmap_len(const LaceRetentionSweep *sweep);
LaceErrorCategory lace_retention_sweep_error_category(const LaceRetentionSweep *sweep);
const uint8_t *lace_retention_sweep_error_message_ptr(const LaceRetentionSweep *sweep);
size_t lace_retention_sweep_error_message_len(const LaceRetentionSweep *sweep);

StoreWork

Binding hosts never receive LaceStoreCall *. For each reported StoreCall side, they issue one opaque LaceStoreWork containing an owned StoreOp, hidden ticket, and empty or complete result construction state.

Before submitting late work, a host checks whether it is still current. Invalid, partial, wrong-family, or stale work cannot partially mutate the machine. Complete results validate when installed on StoreWork; incremental results validate when assembled for submission. Submission consumes work, verifies the exact ticket, fills the machine-owned complete response, and submits once. Late work is freed only after the owning Lace performs required durable mutation classification and position publication.

The detailed operation and result contract is defined by ../../../docs/architecture/store-request-response-ffi-boundary.md.

Request accessors

Accessors are positional and operation-neutral. They take const LaceStoreWork * and return zero, null, or None for a value the operation does not carry. Text and bytes are borrowed until the work is consumed or freed.

void lace_store_work_free(LaceStoreWork *work);
LaceInterlaceSide lace_store_work_side(const LaceStoreWork *work);
uint64_t lace_store_work_id(const LaceStoreWork *work);
LaceStoreOpKind lace_store_work_op_kind(const LaceStoreWork *work);
const uint8_t *lace_store_work_error_ptr(const LaceStoreWork *work);
size_t lace_store_work_error_len(const LaceStoreWork *work);

uint32_t lace_store_work_entry_count(const LaceStoreWork *work);
const uint8_t *lace_store_work_entry_hash_ptr(const LaceStoreWork *work, uint32_t slot);
size_t lace_store_work_entry_hash_len(const LaceStoreWork *work, uint32_t slot);
uint64_t lace_store_work_entry_id(const LaceStoreWork *work, uint32_t slot);
const uint8_t *lace_store_work_entry_bytes_ptr(const LaceStoreWork *work, uint32_t slot);
size_t lace_store_work_entry_bytes_len(const LaceStoreWork *work, uint32_t slot);

uint64_t lace_store_work_read_through(const LaceStoreWork *work);
uint64_t lace_store_work_lower_exclusive(const LaceStoreWork *work);
uint64_t lace_store_work_max_rows(const LaceStoreWork *work);
uint64_t lace_store_work_resume_after(const LaceStoreWork *work);

uint32_t lace_store_work_constraint_count(const LaceStoreWork *work);
LaceStoreConstraintKind lace_store_work_constraint_kind(const LaceStoreWork *work, uint32_t slot);
const uint8_t *lace_store_work_constraint_text_ptr(const LaceStoreWork *work, uint32_t slot, uint32_t part);
size_t lace_store_work_constraint_text_len(const LaceStoreWork *work, uint32_t slot, uint32_t part);
Operation Entries Entry values Scalars
CurrentPosition none — —
ResolveIds requested hashes hash read_through
Find none — read_through (upper inclusive), lower_exclusive, max_rows, resume_after, constraints
LoadHash requested hashes hash —
LoadId requested ids id —
Store records to store hash, canonical bytes —

read_through raw u64::MAX selects the observed high-water. max_rows is u64::MAX when unbounded; resume_after is zero without a cursor (store ids are nonzero). Find constraints are one flattened slot sequence: the exact hash when present, then field constraints, then record-link constraints, each family in request order with duplicates kept. Kinds are ExactHash, FieldPresent, FieldExact, FieldPrefix, and Link; a slot past the end is None. Text part 0 is the hash, field name, or link name; part 1 the field value, prefix start, or link data; part 2 the link target. An absent part is a null pointer with zero length; present empty text is a non-null pointer with zero length.

Result construction

Builders take LaceStoreWork *, return LACE_FFI_STEP_IDLE or LACE_FFI_STEP_ERROR (a null reservation), and record the rejection in the work’s error text without changing filled state. The operation the work holds selects the result family.

int32_t lace_store_work_result_set_position(LaceStoreWork *work, uint64_t position);
int32_t lace_store_work_result_set_complete(LaceStoreWork *work, uint32_t complete);
int32_t lace_store_work_result_add_entry(LaceStoreWork *work, uint32_t slot, uint64_t id, uint32_t duplicate);
uint8_t *lace_store_work_result_reserve_entry_bytes(LaceStoreWork *work, uint32_t slot, uint64_t id, size_t len);
int32_t lace_store_work_result_commit_entry_bytes(LaceStoreWork *work);
int32_t lace_store_work_result_set_error(LaceStoreWork *work, uint32_t kind,
                                         const uint8_t *message, size_t message_len);

Entries fill in slot order starting at zero; request-positional slots stay within entry_count. Find rows use their row index. Nothing echoes request hashes or ids.

Store primitives

One opaque LaceStore holds a Rust store behind the shared StoreBackend implementation. Its constructors choose the backend; every other call is backend-neutral:

LaceStore *lace_store_memory_new(void);
LaceStoreMemoryBuilder *lace_store_memory_builder_new(void);
int32_t lace_store_memory_builder_add_record(LaceStoreMemoryBuilder *builder,
    const uint8_t *bytes, size_t len, LaceErrorOut *error);
LaceStore *lace_store_memory_builder_build(LaceStoreMemoryBuilder *builder, LaceErrorOut *error);
void lace_store_memory_builder_free(LaceStoreMemoryBuilder *builder);
LaceStore *lace_store_fs_open(const uint8_t *path, size_t path_len, LaceErrorOut *error);   /* native-fs */
LaceStore *lace_store_lmdb_open(const uint8_t *path, size_t path_len, LaceErrorOut *error); /* lmdb */
void lace_store_free(LaceStore *store);
int32_t lace_store_current_position(LaceStore *store, uint64_t *position, LaceErrorOut *error);
int32_t lace_store_execute_work(LaceStore *store, LaceStoreWork *work, LaceErrorOut *error);

Native and WASM builds expose the indexed-memory store. Native builds may also expose the filesystem-shaped store or, with default-off feature lmdb, the internal lmdb-lace store. Native open calls accept only the root path; a root’s native capacity is fixed when it is created. Mobile builds do not qualify physical-device address space or app-sandbox access. Finite providers seed indexed memory once through the builder over validated records; build consumes its records. Stores execute StoreWork and fill its result; they do not submit it, own Lace, publish watches, or schedule work.

lace_store_current_position returns LACE_FFI_STEP_IDLE (0) on success and writes the complete position, including valid before-first zero. Failure returns LACE_FFI_STEP_ERROR (-1) and leaves the output unchanged. position names one aligned, exclusively writable uint64_t; it need not be initialized before the call and is read only after success. A null store/output is Integration; a backend failure, including a wrong result family, is Store. Status remains unambiguous when the optional diagnostic pointer is null.

Native store open uses Integration for malformed foreign arguments and Store for backend-open failure. Work execution uses Integration for missing owners or rejected result installation. A backend StoreError successfully installed on StoreWork is a completed execute call, not an execute-status error; the machine consumes that typed result on submission. Free releases directly owned resources, not a runtime participant; there is no separate close operation. Native Rust work-layer consumers continue using lace-sans-io directly.

Private ILTP codec boundary

Runtime drivers allocate one LaceIltpCodec per connected carrier with lace_iltp_codec_new. They push bytes until one item is ready (lace_iltp_codec_push_until_item), take that opaque item (lace_iltp_codec_next), and finalize EOF (lace_iltp_codec_finish). Taking an item returns a step status: an item, no ready item, or a decode failure, never conflating the last two. Before decoding toward each item the machine takes, hosts call lace_iltp_codec_set_record_ingress(codec, machine, error): the carrier’s state decides at each record’s start, before allocation, whether it is legal and within which bound, as the transition tables define; an illegal record fails with PeerProtocol and one over a local limit with LimitExceeded. The codec receives accepted records into memory; a record discarded during reset prefacing becomes an item that carries only its whole item length, including its label line and framing LF, to the machine.

Output is one accepted batch at a time, produced in chunks of at most the codec’s chunk size (64 KiB):

int32_t lace_interlace_machine_accept_peer_output(LaceInterlaceMachine *machine,
                                                  LaceIltpCodec *codec, LaceErrorOut *error);
int32_t lace_iltp_codec_accept_end(LaceIltpCodec *codec, LaceErrorOut *error);
int32_t lace_iltp_codec_output(LaceIltpCodec *codec, LaceErrorOut *error);
const uint8_t *lace_iltp_codec_output_ptr(const LaceIltpCodec *codec);
size_t lace_iltp_codec_output_len(const LaceIltpCodec *codec);
int32_t lace_iltp_codec_advance_output(LaceIltpCodec *codec, size_t written, LaceErrorOut *error);

accept_peer_output accepts the connected carrier’s retained machine output and moves it into an idle codec as one batch; accept_end accepts the outgoing stream’s end mark instead, which hosts write in bounded completion and offer on cancellation at fixed point. Either fails with Integration while the codec still holds output. lace_iltp_codec_output then returns LACE_FFI_STEP_OUTPUT with a nonempty current chunk, borrowed through output_ptr/_len until the next mutating codec call, or LACE_FFI_STEP_IDLE once the accepted output is complete. The host writes some prefix of the chunk and advances by the written count; the rest stays current, so partial writes keep the same chunk and offset. After LACE_FFI_STEP_IDLE the host flushes: each batch’s bytes end with its encoding’s flush, and an end mark may produce no bytes. A batch carrying a preface switches the logical session’s encodings as its bytes are produced, so hosts drain such a batch rather than discard it.

Hosts cannot construct or inspect semantic ILTP items or invoke unrestricted codec operations. This preserves one-item handoff and keeps phase interpretation inside the interlace machine. The codec selects each direction’s Lace-065 encoding from the two prefaces of every logical session it carries and applies it: hosts push and write wire bytes and never select, inspect, or flush an encoding themselves. Decoded bytes following a ready item stay in the codec and are framed by lace_iltp_codec_next before more input is needed.

Deliberately absent

The ABI has no callbacks, store vtables, JSON operation protocol, detached response builders, runtime objects, integer handle registry, FFI futures, workers, clocks, routes, watches, host-visible StoreCall retirement hooks, transport implementation, or duplicate symbol family.

Validation

make check verifies the current ABI declaration baseline, generated header, JavaScript values, target/feature-preprocessed declarations, native C policy calls, link smoke tests, exact WASM exports, and empty-import WASM instantiation. Focused tests cover PyO3 use of the same safe poll/work layer, every StoreOp/result family, direct side effects, stale/wrong work, atomic invalid-result rejection, reserve cleanup, and typed error parity. make check-c-lmdb adds a focused native C link/work test: memory-to-LMDB transfer, explicit host submission, and durable reopen followed by LMDB-to-memory transfer. Each suite stays below 60 seconds.