Contents

Lace-100 · Developer Surfaces

Tags: dx, event-stream, porcelain, developer-contract

Applications use Lace to create records, select them with policy, and exchange them with other participants. They should not need to drive protocol phases or inspect store internals to do so.

This specification defines the shared application contract for Rust, JavaScript, Python, and C: records, participants, operations, observations, and outcomes. Bindings preserve ownership and protocol guarantees using their native call, future, thread, cancellation, and destruction models; identical timing and signatures are not required. Specs 010–065 remain authoritative for the underlying protocol.

Records are immutable values

A validated Record exposes its typed hash, kind (Blob, Plex, or Seal), opaque data bytes, and ordered application field occurrences. Blobs carry data; Plex records add fields; Seals add Mark evidence over a Plex. Looking up a field name returns all its values in canonical occurrence order, never silently one value. Iteration returns every application occurrence in canonical record order. Record-link access returns the parsed links defined by 020. Seals also expose canonical By and Mark text.

Structural fields Data-Length, By, and Mark and synthetic fields :Type and :Length belong to the 020 fact or projection surface, not application-field iteration. All bindings expose the same information and occurrence order through their ordinary byte, string, iterator, and lifetime forms.

Record metadata (hash, kind, lengths, fields, links, By and Mark) is always in memory; the canonical bytes need not be. A record exposes its canonical length and data offset, a bounded read of canonical bytes at an offset, and a contiguous view only where it holds its bytes contiguously, such as a constructed, parsed, in-memory, or mapped record. Materialising the whole record or its data is an explicit call with a caller-supplied size limit that fails when the bytes exceed it. No binding promises a complete slice or reads storage behind a metadata accessor.

Surface Length Bounded read Contiguous view Materialise
Rust canonical_len() read_at(offset, buffer) contiguous_bytes(), contiguous_data() to_vec(limit), data_to_vec(limit)
JavaScript canonicalLength readAt(offset, length) contiguousBytes(), contiguousData() toBytes(limit), dataToBytes(limit)
Python canonical_len read_at(offset, size) contiguous_bytes(), contiguous_data() to_bytes(limit), data_to_bytes(limit)
C lace_record_canonical_len lace_record_read_at lace_record_contiguous_ptr —

A record is owned independently of a store snapshot or protocol machine. No runtime adds a required field, singleton field, application schema, or mutable row identity. Applications define field meanings; policy defines record subsets. Interlace converges the records selected by both sides.

Construction follows the supplied fields

A constructor accepts optional data, an ordered sequence of (name, value) occurrences, and an optional private By-secret value. It selects the form exactly as follows:

empty specification                    -> Blob with empty data
data, no fields, no By-secret           -> Blob
one or more fields, no By-secret        -> Plex
By-secret with no fields                -> error
By-secret with one or more fields       -> Seal over Plex

Omitted data means empty bytes. Constructors never manufacture fields, timestamps, identifiers, scopes, or placeholders. String data convenience input is UTF-8; binary APIs accept the language’s ordinary byte value. Constructors apply 010 validation and sorting, preserving caller order among same-name occurrences and rejecting reserved names or invalid names and values.

Surface Data Fields Marking input
Rust byte value ordered (name, value) sequence BySecret
JavaScript byte value or UTF-8 string convenience ordered pairs bySecret
Python bytes or UTF-8 string convenience ordered pairs by_secret
C builder data setter repeated field append By-secret setter

Rust uses ByValue for canonical public V.<b64a>.H3 text and one zeroizing BySecret owner for canonical private &.<b64a>.H3 text. BySecret::by_value() derives the public value; Record::by() returns it only for a Seal. JavaScript, Python, C, facts, and wire surfaces use the same canonical public text, not raw key bytes or another public-key representation. Public By text is output, never a substitute for the private marking input. Rust BySecret::from_secret_key and JavaScript bySecretFromBytes build the private text from an application-supplied 32-byte payload, whose secrecy is the application’s (009).

Construction returns a validated owned Record. Parsing canonical bytes is a separate validating protocol boundary, not another construction specification.

Record streams arrive one validated record at a time

Reading a 010 record stream yields its records in stream order, each once its bytes are validated. A record is received as an ILTP response would be: into the given Lace’s record staging when its store has staging, otherwise into memory. Reading admits nothing; store or store_stream offers the records to a target. A staged record holds its staging file until the record drops.

The reader takes a byte bound for the whole stream. A record whose canonical length exceeds what remains of it fails with LimitExceeded at its start, before any of its bytes are held. An invalid record, bytes after a record that do not form one, or a stream ending inside a record fail with PeerProtocol; a failed read is Transport and a failed staging write Store. A failure ends the stream and discards the partial record and its staging. An empty stream yields nothing.

Surface Reader
Rust lace-rt read_record_stream(&handle, impl Read, max_bytes) -> RecordStream, an iterator of Result<Record>
Rust lace-tokio read_record_stream(&handle, impl AsyncRead, max_bytes) -> RecordStream, next().await
JavaScript readRecordStream(lace, ReadableStream<Uint8Array>, { maxBytes }), an async iterable of Record
Python read_record_stream(lace, reader, *, max_bytes), an iterator of Record over a binary reader

Participants and operations

A Lace owns an adopted store and its lifecycle. A clonable opaque LaceHandle gives operations access to that participant without exposing the store. A clonable LocalPeer pairs a handle with its own prepared policy operand: Rust LocalPeer::new(handle, policy), JavaScript new LocalPeer(lace, policy), Python LocalPeer(lace, policy); preparation failure is a construction error. A ConnectedPeer instead owns host-established transport IO and is claimed by one operation.

Direct interlace joins two local participants; connected interlace joins a Lace or LaceHandle and a connected peer. A bounded operation returns a settled outcome; an open operation returns a handle for continued observation. The ordinary surface is:

open_interlace / openInterlace(first, second, policy) -> open handle
bounded_interlace / boundedInterlace(first, second, policy) -> bounded outcome

list(target, policy, options) -> ListOutcome
get(target, policy, options) -> GetOutcome
store(target, records, options) -> StoreOutcome
store_stream / storeStream(target, options) -> offer stream
for_each / forEach(target, policy, options) -> observation stream

The convenience operations, called porcelain, create private participants and run ordinary interlace. Public record access always follows policy, exposure, and interlace; it does not bypass them through raw database access or remote commands. The signatures above describe operation roles, not a common calling convention. JavaScript uses openInterlace(first, second, options) and boundedInterlace(first, second, options), with policy inside options; list, get, and forEach take positional policy followed by options. store takes records followed by options and no caller policy. JavaScript open setup returns a promise for its handle; it does not await peer establishment or fixed point. Rust synchronous and Python opening remain synchronous; Tokio uses a future. Record collections follow each binding’s native input forms.

Each operation retains a handle for every local participant position. Both positions may refer to the same Lace without changing behavior or outcomes. Bounded interlace and list, get, and store return outcomes, not participant handles. Callers retain or clone handles beforehand when they need reusable local access. A claimed connected participant is consumed and settled by a bounded operation. Neither Lace, LaceHandle, nor LocalPeer exposes a raw store, socket, transaction, or protocol machine.

Policy selects records and permits inspection

The ordinary string policy is anonymous Interlang:

converge:
  include Record:
    Record['Content-Type'] == 'text/plain'

This selects records with exactly one Content-Type occurrence equal to text/plain. A bounded format test uses, for example, Record.Name matches '[a-z][a-z0-9-]{0,31}'; see 120 operator tests and 030 TextMatch. It uses the existing policy APIs, not a separate matching API. Operations also accept prepared canonical operands. Source conveniences accept one self-contained contribution. Compile independent contributions separately and compose their prepared policies as described under Preparing policies.

converge selects stored records, generates request discovery, and implies whole-record exposure. expose alone permits peer-origin inspection without transfer. A record leaves a side only when that side’s policy both selects and exposes it, and the peer’s policy also selects it. There is no implicit select-all source; advanced Datalog can use explicit Have. Every binding publishes the prepared all-records policy, which selects, exposes, and requests every record: Rust policy::all_records(), JavaScript allRecords(), Python all_records(), and C lace_policy_all_records().

Ordinary interlace is duplex. Its policy argument is an operand template, not a merged executable or exchange plan. In a direct operation, a bare Lace or LaceHandle position receives it and a LocalPeer position receives its own operand, in argument slot order. At most one position may be a LocalPeer, and a LocalPeer cannot join a ConnectedPeer; either violation is a Policy call error. Porcelain accepts a LocalPeer only as its target. In a connected operation, the local participant contributes one operand and the peer supplies the other as its role-assigned operand, which crosses as a Blob only when the local Lace does not hold it. An operand may derive TxDisabled/0 or RxDisabled/0; direction is policy, not a separate option.

Bounded operations return; open operations wait for more

Both APIs reach the same 050 fixed point: neither side requested another record in the settled round. A bounded operation returns after required final output and raw transport release. An open operation waits on each local Lace’s retained position-or-terminal-failure signal and may start another round after local store progress.

Protocol fixed point is not yet a successful public outcome: the channel owner must accept required output and the route must settle. The output batch accepted at the transition into fixed point, the settled round’s final request block, retains its original absolute phase deadline until fully written and flushed to the adopted IO. Neither API publishes FixedPoint before that boundary; bounded completion also waits for route settlement as 050 defines. A connected route settles once the local writer has shut down and the peer’s direction has ended; an end-mark write or writer shutdown failure still settles when the peer’s direction ended cleanly.

Operation options are the five local, unnegotiated 050 lifecycle settings: received control bytes, transferred record bytes, evaluation work, peer operand bytes, and phase timeout. All but the phase timeout produce LimitExceeded; phase expiry produces Timeout. Parser/wire caps, transport buffers, store-position discovery, and polling cadence are not operation options.

A runtime may expose physical IO configuration: the size of the chunk buffers that carry transport bytes, stream encodings, file-range record reads and store copies, and a budget those buffers reserve from. Rust lace-rt and lace-tokio each set both process-wide for their own runtime with configure_io(IoSettings { chunk_bytes, budget_bytes }); work set up afterwards uses them. A connected operation reserves its chunk buffers during setup, a record stream reader its read buffer, and a store that stages received records its copy buffer during adoption, so an exhausted budget is a LimitExceeded call error before driving; reservations return when the operation’s IO is released, the reader drops or the store is closed. Records, direct operations and in-memory record assembly are not counted.

Observations report records and lifecycle

Bindings expose one tagged observation union:

RecordSent { kind, position, record_hash }
RecordSaved { kind, position, record_hash, optional owned Record }
FixedPoint { kind }
Closed { kind, reason }

Only record observations carry first or second, matching application argument position (ParticipantPosition::First and Second in Rust). This identifies neither protocol side nor authority, locality, initiator status, or durable receipt. FixedPoint and Closed are operation-wide. Open streams, including for_each, attach the owned Record to RecordSaved; bounded outcomes report only its hash. Round numbers, fixed-point identities, store positions, raw binding dictionaries, and protocol phases remain diagnostics, not required observation fields.

Every host exposes one-event reading as the canonical open-operation primitive. Iteration delegates to it. A run-to-fixed-point helper returns every observation it consumed through FixedPoint or Closed; it neither silently discards them nor runs a second driver. Result collectors use these same observations and fixed-point state, not additional protocol event families.

Clean closure yields Closed once, then exhaustion. Terminal failure returns one typed error, without a synthetic event, then exhausts. The failed handle retains that error for later methods such as policy replacement. Queues and transport buffers remain bounded: a consumer that stops reading causes backpressure, cancellation, or closure rather than unbounded growth.

Rust open and observer handles also expose consuming close(self) (async in Tokio). It settles started work and connected transport ownership, then returns the final Closed(Cancelled) boundary or observer event. Drop is a non-waitable resource-safety fallback.

Close preserves failures through one settled boundary

Explicit close MUST attempt all owned cleanup and finish required started work before reporting its result. An existing operation or participant failure remains visible; a distinct cleanup failure MUST NOT replace or disappear behind it. Bindings preserve the primary failure and additional failures through their existing error channel, not a cleanup event or separate status API. JavaScript uses AggregateError when failures combine, retaining original reason values; typed projections MUST preserve that group as a cause rather than flattening it to message text. After driving starts, bounded wrappers preserve partial results and use a structured failed terminal for cleanup failure rather than successful completion or a stopped-by-limit terminal that hides the failure. Failed setup and empty-store disposal errors remain call errors because driving never started.

Where close is repeatable, concurrent and later calls observe the same pending or settled result. A replayed retained failure is not another cleanup failure. For JavaScript, cancellation/failure presence MUST be tracked independently of the reason’s truthiness; false, 0, '', null, and NaN remain exact rejection values when no additional failure combines with them. Both pending and newly issued event reads MUST respect cancellation settlement before reporting its result; queued success or exhaustion cannot bypass that boundary.

Settled cleanup means required attempts and owned work finished, not that arbitrary IO implementations physically released everything or that a peer acknowledged retirement. Error identity supplies no additional cleanup-status guarantee.

Bounded outcomes preserve partial results

Except for caller cancellation, a bounded operation returns exactly one terminal:

The outcome owns its terminal and ordered observations. List, get, and store outcomes also own records or per-input results. Only failed carries an error; stopped-by-limit has no error payload. Caller cancellation uses the language-native call cancellation path after cleanup, not a bounded terminal.

Argument validation, policy preparation, participant claiming, route setup, and driver construction precede operation start. Failures there are typed call errors. Once driving starts, failures become outcome terminals and preserve prior observations.

A complete operation collects record observations in production order, settles obligations, final output, and its route, appends FixedPoint, then returns. A limit or failure returns prior observations without FixedPoint or an error observation. Bounded teardown does not append Closed. Empty store is the explicit exception: it completes without interlace or any observations.

Only a bounded wrapper maps core LimitExceeded to stopped-by-limit. Phase expiry is failure; scheduling yields and would-block states are not outcomes. Boundary EOF is complete only at the 050 fixed point; premature semantic EOF is failure. Open streams end by cancellation, closure, or failure, not a bounded completion status.

Errors are typed across runtimes

Rust, C, WebAssembly, JavaScript, and Python share these categories: PeerProtocol, LimitExceeded, Timeout, Policy, Store, Transport, and Integration. A structured error carries its category, a human-readable message, and optional participant position and record hash. Only Timeout carries a stable phase: Prefacing, Side0Proof, Side1Proof, Side0Operand, Side1Operand, Advertising, Requesting, Answering, or Fencing. Callers need not parse debug text. Errors are not observations.

Malformed, truncated, oversized, or noncanonical incoming framing is PeerProtocol; local encoder or construction-invariant failure is Integration. A store refusing an admission at its capacity is LimitExceeded; every other store failure is Store. Connected local failure closes transport. The peer may see transport failure, framing truncation, or premature EOF rather than the local error details.

Porcelain runs ordinary interlace

list and get capture selected records

list creates a private capture Lace and returns only records validated and admitted there, collected from ordinary RecordSaved observations. Records captured before a limit or failure remain in the outcome; its terminal indicates that the result is not exhaustive. ILTP defines no result order. An API promising order defines it separately.

get captures one result: the first record admitted to its private capture Lace. No recency rule changes that choice. The result survives a later limit or failure, but the operation still attempts to settle outstanding obligations and mandatory follow-up rounds. Empty selection completes normally.

store offers the supplied records

store creates a provider Lace containing exactly the supplied validated records. The target’s policy decides what to request and persist; a bare local target, which has no operation policy, uses the all-records operand, and a LocalPeer target uses its own operand. Empty input is a complete no-op: no interlace, record results, observations, or synthetic FixedPoint. It still consumes and releases an accepted connected input under the binding’s ownership rules below. Explicit cleanup failure MUST NOT be reported as successful no-op completion.

For nonempty input, each unique record receives one route-neutral disposition:

Disposition Meaning
Saved the provider emitted RecordSent and the bounded operation returned complete
Skipped the operation returned complete without emitting that record
Unsettled the operation did not return complete, whether or not it emitted the record

An internal fixed-point transition followed by final-output or route-settlement failure, including output failure the channel owner reports at shutdown, does not report Saved. RecordSent followed by fixed point confirms settled target admission, including a duplicate admission after a frozen request. It does not promise a new physical insertion, filesystem flush, or independently attested persistence, nor vouch for a nonconforming peer.

Skipped deliberately leaves already-held and policy-declined indistinguishable on direct and connected routes; a complete outcome never calls a sent record Skipped. Optional provenance fields use provider_sent for local RecordSent and target_saved for protocol-confirmed settled admission, true exactly for Saved.

store_stream offers records one at a time

store_stream is the open counterpart of store, as for_each is of list. It creates an empty private provider Lace, opens an interlace with store’s operands against the target, and drives it to the first fixed point before returning; failure until then is a call error.

Each offer(record) stores the record into the provider and drives to the next fixed point. It returns Saved when the provider emitted RecordSent for that record before the fixed point, under store’s settled-admission rule, and Skipped otherwise, at once and without a round when the provider already held the record. A skipped record stays in the provider: a later offer that makes the target select it sends it in a later round, while its own offer remains Skipped. Offers run one at a time.

An error, including an offer interrupted before its fixed point, ends the stream: later offers return it, and records saved before it stay saved. A peer that closes a connected target before an offer’s fixed point leaves that offer unsettled; the stream ends with PeerProtocol. close cancels the interlace and closes the provider, which releases the offered records, and returns the error that ended the stream with any cleanup failure under the close rules above.

Surface Open, offer, close
Rust store_stream(target), store_stream_with_options(target, options); offer(record) -> StoreRecordDisposition; consuming close(), all async in Tokio
JavaScript storeStream(target, options?) resolves a StoreStream; offer(record) resolves 'saved' or 'skipped'; close()
Python store_stream(target, *, limits=None); offer(record) returns 'saved' or 'skipped'; close() and context exit

for_each observes without sending records back

for_each creates a persistent observer Lace. Its stream contains at-most-once RecordSaved observations with the observer-owned validated Record, plus operation-wide FixedPoint and Closed. It exposes no RecordSent, error events, callback queue, or record-only subscription. Observer records do not flow back to the target.

A Lace owns its store until close

Built-in constructors include:

Rust/native:  Lace::filesystem(...), Lace::memory(...)
Rust:         Lace::portable(...), Lace::memory_with_capacity(records, bytes)
Optional LMDB: Rust Lace::lmdb(...), Python Lace.lmdb(...)
JS/core:      Lace.memory(...)
JS/browser:   Lace.opfs(...), Lace.browserLocalStorage(...), Lace.openBestEffort(...)
JS/Node:      Lace.filesystem(...)

Lace::memory_with_capacity(records, bytes) is a memory Lace holding at most bytes of canonical record bytes, counted once per record, so a duplicate holds nothing. It is a logical quota: indexes and allocator overhead are not counted. Seeding beyond it fails construction with LimitExceeded. An admission beyond it fails that operation with LimitExceeded before anything is admitted, so the store position does not advance and no open operation wakes; other operations continue. Close releases the count; a record a reader still holds stays readable on its own. held_bytes() on such a Lace reports the canonical bytes it holds now, and None on any other Lace. The JavaScript, Python and C memory constructors are unbounded.

The non-clonable Lace exclusively adopts one concrete store object. It owns mutation publication, retained position-or-terminal-failure state, external-position polling coordination, and lifecycle. LaceHandle duplicates none of that state.

A store position is a measure of local admission progress, monotonic within one adoption of a root, not a record identifier or public cursor. At adoption, the runtime fixes immutable position_may_advance_externally: false guarantees position advances only through that Lace’s mutations; true allows outside advances and enables fixed internal polling. This is adoption metadata, not a store operation, policy/protocol input, or operation option. Built-in constructors fix it internally.

Filesystem and LMDB roots

The native filesystem constructor adopts false and exclusively owns its physical root until close or final owner drop. Independent openers in the same or another process, including path aliases, fail without waiting. Same-process operations share Lace handles. Cooperating processes needing one shared root use optional LMDB.

The LMDB constructor is present only with the consumer’s lmdb feature. Enabling that feature for a target other than Linux x86-64/AArch64 with glibc or musl, macOS x86-64/AArch64, Windows x86-64/AArch64 MSVC, Android AArch64/x86-64, and iOS AArch64 devices and AArch64/x86-64 simulators fails the build. Linux glibc and musl processes may share one root. It adopts true. Each process has only one independently opened environment per physical root, including supported aliases. LMDB files are trusted local storage, not an untrusted database-file import surface. Forked children do not use inherited LMDB stores or records and exec before reopening.

Both native constructors take a root path; Python’s also accept the operand_cache keyword below. A root’s capacity is fixed when it is created: the platform default, or the byte count in LACE_NATIVE_STORE_CREATE_CAPACITY. Reopening uses the recorded capacity and ignores the variable. The native capacity rule defines the defaults and valid values.

Forking with live native filesystem store or Lace objects is unsupported. Children MUST NOT use inherited store or Lace state. Fork before constructing these objects or exec before reopening; an existing owner’s filesystem root remains exclusive.

Portable file roots

The portable constructor keeps records in plain files that also work on a FAT card, such as an ESP-IDF device’s SD card. It takes a root path and a format whose segment and index sizes apply only when the root is created; reopening uses the recorded sizes. It adopts false and exclusively owns its root until close or final owner drop: another opener in the same process fails, and so does one in another process where the platform has file locks. Records it returns stay readable after close. It has no compaction. The portable file store note defines its layout and recovery.

Advanced custom-store adoption

Python exposes:

lace.advanced.with_store(
    store,
    *,
    position_may_advance_externally: bool,
) -> Lace

The keyword is required and must be an actual bool, validated before reading the store’s initial position. Built-in constructors pass their fixed values internally. Rust lace-tokio and lace-rt expose no public custom-store adoption surface. JavaScript exposes no generic custom-store adoption surface or corresponding metadata argument.

Open operations notice external progress

While at least one relevant open operation waits at fixed point, a Lace adopted with true performs finite authoritative CurrentStorePosition polling. All such operations share Lace-owned cadence and single-flight coordination: at most one physical probe per fixed internal interval. Bounded operations and open operations outside fixed point do not request polling.

An advance uses the same monotonic retained publication path as local mutation. An external probe or confirmed-position publication failure terminates the Lace as described below. Polling adds no public interval, notification source, protocol state, limit, or wire grammar.

A Lace holds received peer operands

Each Lace owns one operand cache, shared by its connected operations, so that a later session receives a peer operand the Lace holds as its hash only (050). The setting counts entries, most recently used first, and defaults to 32; 0 holds none, so every peer operand crosses as a Blob. The cache is memory only: a restart costs one Blob per peer operand. list, get, store, store_stream, and for_each over a connected peer use a private Lace, whose cache starts empty.

Surface Setting
Rust lace-rt / lace-tokio Lace::with_operand_cache(self, entries) -> Lace
JavaScript operandCache in each Lace constructor’s options
Python operand_cache= keyword on each Lace constructor

Close drains started work before releasing resources

Rust close consumes Lace: close().await in lace-tokio, close() in lace-rt. JavaScript awaits Lace.close(); Python calls Lace.close(). Close:

  1. prevents new work through every retained handle;
  2. requests closure of directly owned operations where the binding provides that relationship;
  3. waits for started reads, mutations, and authoritative position refreshes, including durable classification and confirmed position publication; and
  4. releases the adopted store’s resources.

Independent operation handles remain separately closeable but cannot begin new store access. Retained Lace handles are invalid after close. Repeated language-level close observes or waits for the same boundary. Low-level C/WASM free() destroys allocations only; it is not participant lifecycle.

An external-position probe or confirmed-position publication failure retains the first terminal Store or Integration error on the Lace. It rejects later claims and store execution, terminates every subscribed operation with that same error, and returns it from close after non-cancellable started work drains. Ordinary operation-local store failures do not poison the Lace.

Native close/drop does not publish posting lists. Filesystem close releases exclusive root ownership; LMDB close releases only that process’s environment. Where deterministic drop exists, resources remain alive while any handle, operation, or started work can access them; the final owner closes the concrete store. JavaScript requires explicit close for a deterministic boundary.

Native filesystem stores release their kernel lock on the persistent lock rendezvous file explicitly before closing its descriptor, never by deleting the file; a descriptor copy held by a forked child that has not yet exec’d does not extend ownership. Node removes its owned lifetime sentinel on clean close; browser Web Locks release ownership without a lock file. Close and deterministic drop neither probe PIDs nor remove possibly stale crash residue automatically. Process/realm termination releases kernel locks and Web Locks but may leave a Node sentinel.

Compacting a root removes unselected records

Compaction shrinks a durable root in place. It is an offline call on a root with no open Lace; live operations never delete records.

Rust/lace-rt:     compact_filesystem(root, policies) -> CompactionReport
                  compact_lmdb(root, policies)              (feature `lmdb`)
Rust/lace-tokio:  compact_filesystem(root, policies).await, compact_lmdb(...).await
Python:           lace.compact_filesystem(root, policies), lace.compact_lmdb(root, policies)
JS/Node:          Lace.compactFilesystem(path, policies, { wasm?, wasmUrl? })
JS/browser:       Lace.compactOpfs(nameOrHandle, policies, { lockName?, wasm?, wasmUrl? })
                  Lace.compactBrowserLocalStorage(name, policies, { storage?, singleTab?, ...store limits, wasm?, wasmUrl? })

policies is a nonempty collection of the policy inputs operations accept. A record is retained when at least one policy’s stored-selection facet (SelectStoredRecord) selects it, evaluated alone over the root’s complete local view at the root’s high-water; every other record is removed. Exposure and directional vetoes are ignored. A collection with no stored-selection rule, or a policy whose stored selection depends on Here or PeerConfidential, is a Policy error; compaction MUST NOT evaluate absent session authority as false. Selection is one pass over the unmodified root: a policy that relies on evidence it does not itself select loses that evidence, and a later compaction may remove more.

A compacted root restarts its StoreRecordId and StorePosition numbering contiguously from the first retained record, preserving relative admission order; position monotonicity holds within one adoption. Removal is not retraction: a peer may resend a removed record that an exchange policy still requests.

The root must have no open Lace in any process. A native arena-backed Record keeps its root’s arena in use after its Lace closes; compaction fails with Store while any such record or Lace of the root is alive anywhere, and with Store on any other lock contention. While compaction runs, opening the root fails with Store on every backend.

The call returns CompactionReport { boundary, retained, removed, bytes_released, release }, where release is Ok or Unsupported when the filesystem cannot release the freed range after the root is already consistent. Errors are Policy for retention-policy preparation, LimitExceeded for a sweep evaluation bound, and Store for IO, corruption, contention, or live records. A compaction interrupted by failure, Tokio future drop, Python interruption, or process termination MUST leave either the unchanged root or a root that ordinary opening rejects and a compaction call for that backend resumes. Resuming completes the interrupted selection and does not evaluate the supplied policies. Interruption takes effect before the next sweep store operation or move batch; dropping a Tokio future is not a waitable boundary. Store compaction defines the sweep and the physical algorithm.

Connected peers adopt application-owned IO

Applications establish a byte channel; Lace carries ILTP over it. A move-only, one-use ConnectedPeer atomically adopts either one runtime-native duplex IO value or independent peer-to-local reader and local-to-peer writer owners, alongside an explicit carrier role, optional local By-secret, proof assurance, optional stream encodings, optional port, and optional expected peer By.

Rust spells the constructors ConnectedPeer::from_duplex(...) and ConnectedPeer::from_split(...); bindings use idiomatic factories. No intermediate public transport object is required.

Construction fixes role before decoding any ILTP byte: CarrierRole::Listener is side0, CarrierRole::Connector is side1. Paired endpoints use complementary roles, never inferred or negotiated. Role is neither participant position nor application identity or policy authority.

Construction defaults to Disabled proof assurance. ConfidentialChannel asserts that the complete channel is confidential, integrity-protected and endpoint-confined. ChannelBound(binding) additionally binds live proofs to the established protected channel. Each logical session begins with ILTP 0.13 prefaces, role-ordered optional Seal proofs and operands announced by hash. Before policy evaluation the runtime freshly freezes Here(local_key) and PeerConfidential(peer_key) when present. Connected peer authority requires the live peer’s v3 proof of the matching By-secret together with the supplied assurance; under Disabled, a By-secret supplies Here and no proof is sent. Direct operations carry neither fact. Endpoint configuration and diagnostics supply no policy facts, plan/proof inputs, or protocol authority.

ConfidentialChannel does not enable encryption or authenticate the transport. Its assertion includes proxies, tunnel endpoints, and plaintext forwarding legs. Raw unbound ILTP proofs are fresh but not bound to the encryption session: an untrusted terminating intermediary can relay live proofs between separate encrypted channels. Anonymous encryption or TLS with peer verification disabled is insufficient by itself. PeerConfidential relies on the host excluding that intermediary, as defined by 050.

Applications own endpoint configuration/parsing, dialing, listening, acceptance, transport handshakes, admission, process creation/supervision, and task/service lifecycle. Lace supplies no shared endpoint enum, configuration, parser, dialer, listener, or process surface. Host admission counts and transport buffers are not interlace operation limits.

Adopting a bound channel

Lace implements no transport encryption. The application completes any protected channel establishment, including final handshake-output drain, before adoption. Its channel implementation owns setup deadlines, cancellation, cipher state and binding derivation. The binding is exactly 32 opaque bytes satisfying 050’s channel contract. It is not an endpoint, certificate fingerprint, remote identity or arbitrary shared nonce. The local implementation is trusted to supply protected IO and its binding; Lace signs and compares that binding without interpreting a transport method.

Surface Bound assurance input
Rust ConnectedPeerOptions::with_peer_proof(PeerProofAssurance::ChannelBound(ChannelBinding::new(bytes)))
JavaScript peerProof: 'channel-bound', channelBinding: bytes
Python peer_proof='channel-bound', channel_binding=bytes
C/WASM channel-bound assurance mode and 32 binding bytes

JavaScript and Python also accept disabled and confidential-channel; Python exposes those values through PeerProofAssurance. They copy binding bytes during construction. Missing/wrong-length binding, a binding supplied with another mode, or bound assurance without a local By-secret is a construction error before ILTP IO. Bound assurance requires a nonempty valid peer proof even without a pin. Without a pin, that proof authenticates the presented By, not an intended service or person. Policy still determines permission.

The adopted writer’s flush completes once the channel implementation has accepted ownership of all preceding plaintext with bounded buffering. It then owns that output’s encryption, delivery or failure; flush does not mean the output reached the network. The final accepted batch’s original phase deadline covers flush before fixed-point publication. Shutdown MUST settle required owned work, including accepted output, and report its failure; transport failure MUST surface as IO error, not clean EOF. These are IO-provider obligations, not a promise that arbitrary byte-stream wrappers implement them.

Policy replacement retains the established channel and binding while repeating fresh ILTP proofs. Empty-store no-op runs no ILTP but may release an already established protected channel. Neither construction nor opening an interlace promises that the peer’s By has been verified. Standard browser WebSockets expose no TLS exporter; their URL does not supply a channel binding.

Requiring a particular peer By

Connected construction accepts an optional rejection-only expectation:

Surface Input
Rust ConnectedPeerOptions::with_expected_peer_by(ByValue)
JavaScript expectedPeerBy, optional canonical V.<b64a>.H3 text
Python expected_peer_by, optional canonical V.<b64a>.H3 text

When an expectation is present, final resolved proof assurance MUST be ConfidentialChannel or ChannelBound(binding), explicitly supplied with the adopted IO. The expectation never enables trust. Invalid text or an incompatible assurance setting MUST fail the constructor through its existing configuration-error path before protocol IO. Unbound verification does not require a local By-secret; bound assurance requires one.

Each logical session checks the live, fully validated peer proof against the expectation before advancing the received proof phase, before operands or application-record work. A missing/empty proof or valid wrong-key proof is PeerProtocol with explanatory detail; malformed proofs retain normal validation errors. The expectation supplies no fact, callback, generic required fact bag, plan input, or authority, and does not change proof bytes or profiles.

Constructors and open-handle setup do not await proof. Failures during driving use the ordinary typed failed bounded outcome or open error channel, including retention on a failed open handle. Policy replacement rechecks fresh proof against the same carrier-owned expectation; changing it requires a new carrier. Direct pairs run no proof and accept no such expectation. Empty store still runs no interlace or proof. Neither direct operation nor empty-store completion claims connected peer authentication.

Reading the peer’s proved By

An open connected interlace, and the outcome of a connected bounded interlace, report the By the live peer proved in the latest logical session: the key of that session’s PeerConfidential fact (050). It is absent before that proof validates, under Disabled assurance, after an empty proof, from a re-preface until the fresh proof validates, and on direct operations. It is a diagnostic, like the peer’s port: never a policy fact or authority, and not an expectation. Where a closed open handle remains readable, it still reports the value.

Surface Open interlace Bounded outcome
Rust OpenInterlace::peer_by() -> Option<ByValue> BoundedInterlaceOutcome::peer_by
JavaScript peerBy, a string or null peerBy
Python peer_by, a str or None BoundedInterlaceOutcome.peer_by
C/WASM lace_interlace_machine_peer_by_ptr and _len —

Compressing connected streams

Connected construction accepts one optional ordered list of 065 stream-encoding tokens:

Surface Input
Rust ConnectedPeerOptions::with_stream_encodings(tokens), ordered token names
JavaScript streamEncodings, array of token strings
Python stream_encodings, sequence of token strings

The list is the carrier’s encode preference (Encode) and its decode set (Decode). Omitting it offers every encoding compiled into the build, zstd20c before deflate12c. identity is always implied and never listed, so an empty list offers identity only. An unknown, uncompiled, repeated, or identity token MUST fail the constructor through its existing configuration-error path before protocol IO.

Each logical session’s preface pair selects each direction’s encoding, so the two directions may differ; the list persists across policy replacement. Observations, limits, and errors are unchanged, and operation budgets count decoded bytes. Direct pairs and empty store encode nothing. Compressed ILTP bytes gain nothing from transport compression such as WebSocket permessage-deflate; hosts that negotiate it can decline it.

An open connected interlace, and the outcome of a connected bounded interlace, report the token of each direction’s encoding that the latest preface pair selected: outgoing for local-to-peer bytes, incoming for peer-to-local bytes, identity included. Both are absent before the first peer preface and on direct operations, and each peer preface selects them again. They are diagnostics, like the peer’s port: never policy facts or authority. Where a closed open handle remains readable, it still reports them.

Surface Open interlace Bounded outcome
Rust OpenInterlace::outgoing_encoding(), incoming_encoding(), each -> Option<StreamEncoding> BoundedInterlaceOutcome::outgoing_encoding, incoming_encoding
JavaScript outgoingEncoding, incomingEncoding, each a string or null outgoingEncoding, incomingEncoding
Python outgoing_encoding, incoming_encoding, each a str or None BoundedInterlaceOutcome.outgoing_encoding, incoming_encoding
C/WASM lace_interlace_machine_outgoing_encoding_ptr/_len, lace_interlace_machine_incoming_encoding_ptr/_len —

A compressed direction ends with an end mark. Bounded completion and replacement write it; cancelling an open operation at fixed point writes it only when the writer accepts it immediately, and terminal failure never writes it. A peer that sees EOF without the end mark reports framing truncation as PeerProtocol rather than Closed(PeerClosed).

lace-sans-io compiles deflate12c with its deflate feature and zstd20c with its zstd feature, both off by default. lace-rt, lace-tokio, and lace-ffi enable both by default through same-named features, so the standard JavaScript and Python builds include both. Constrained builds select features themselves.

Naming a port and selecting by it

A connected peer may name what it wants to talk to over its carrier: a port, sent in every preface the carrier emits, with the text 060 defines. A port selects the receiving side’s policy, and in Rust may also select its local Lace on the carrier’s first session; it supplies no fact, identity, or authority (050). Without a port a carrier sends none.

Surface Outgoing port Peer’s port
Rust ConnectedPeerOptions::with_port(PortText) OpenInterlace::peer_port() -> Option<PortText>
JavaScript port, a string peerPort, a string or null
Python port=, a str peer_port, a str or None
C/WASM port argument of connected-carrier construction lace_interlace_machine_peer_port_ptr and _len

Invalid port text MUST fail the constructor through its existing configuration-error path before protocol IO. The peer’s port is the one its latest preface named, absent before the first preface and on direct operations; it is a diagnostic.

An open interlace of one connected peer and a local Lace may take a selector in place of its policy. The carrier calls the selector once per logical session, including after every re-preface, with the peer’s port or none, before it announces an operand. The selector returns that session’s policy with an optional expected peer By, or refuses, which fails the operation with PeerProtocol (port refused). The returned expectation is checked at the session’s proof phase as one supplied at construction is, under the same assurance requirement. Supplying both a policy and a selector, a selector on another route, or a connected peer constructed with an expected peer By is a Policy call error. Policy replacement on the operation returns Policy. A fixed-policy operation serves whatever port its peer names.

In Rust the operation is opened with the peer alone, and the selector returns the local Lace with the session’s policy. The Lace returned for the carrier’s first session serves every later one, so a later call must return it again; another Lace refuses the session like None. Until the first selection the operation holds the transport and its prefacing deadline but no Lace, so closing or failing before it settles no store work; the selection installs the Lace’s operand cache, record staging and position subscription, after which the operation behaves as one opened on that Lace. Because its store is not yet known, a selecting operation reserves its IO chunk buffers for a staging store. JavaScript, Python and C open the operation on a given Lace, and their selectors return the policy alone.

Surface Selector form
Rust open_interlace_with_selector(peer, selector, options), async in Tokio; selector: FnMut(Option<&PortText>) -> Option<(LaceHandle, SessionOperand)>, where SessionOperand::new(policy).with_expected_peer_by(by)
JavaScript openInterlace(lace, peer, { selector }); selector(port) returns or resolves to { policy, expectedPeerBy? } or null
Python open_interlace(lace, peer, selector=selector); selector(port) returns SessionOperand(policy, expected_peer_by=None) or None
C/WASM null policy at connected-carrier construction; the SelectOperand poll, answered by lace_interlace_machine_select_operand

A JavaScript or Python selector that throws or returns a malformed value fails the operation with Integration, retaining the thrown value as its cause; a returned policy that fails preparation fails it with Policy. A returned expectation without confidential-channel or channel-bound assurance fails it with Integration.

JavaScript byte channels

Core, browser, and Node entries expose:

ConnectedPeer.fromSplit({ readable, writable, role, bySecret?, peerProof?, channelBinding?, streamEncodings?, port?, expectedPeerBy? })
ConnectedPeer.fromWebSocket({ socket, role, bySecret?, peerProof?, channelBinding?, streamEncodings?, port?, expectedPeerBy? })

fromSplit adopts unlocked ReadableStream<Uint8Array> and WritableStream<Uint8Array> owners. The source supplies owned chunks with bounded buffering. The sink’s write promise MUST settle only after the sink has accepted ownership of the chunk within bounded buffering; its close MUST settle that owned output and reject on its failure. Source cancellation and sink abort retire physical IO. Pending writes and close operations MUST respond to the writable controller’s abort signal, including rejecting active work on IO failure. Lace cannot provide these guarantees for an arbitrary sink; a platform stream conversion alone does not establish them.

fromWebSocket adopts an already-open standard WebSocket with no pending output. It installs ingress handling before asynchronous initialization; initialization failure retires the socket. Binary messages carry consecutive ILTP bytes without semantic message boundaries. Ingress is bounded; overflow is transport failure. Its writes resolve when bufferedAmount reaches zero. Completion initiates socket closure without waiting for peer acknowledgement; failure or cancellation closes without draining. Platform-queued bytes may still arrive. Neither the URL nor successful adoption supplies proof assurance; dialing, TLS, and admission remain application-owned.

Accepting connected inputs before prepared construction

Connected inputs are consuming arguments to bounded/open interlace, list, get, store, store_stream, and for_each. Responsibility for the input precedes prepared participant claiming into the runtime driver. Neither accepting an input nor preparing policy starts protocol driving. Every accepted resource MUST have exactly one owner through preparation, construction, execution, and teardown.

Runtime Input acceptance
Synchronous Rust By-value arguments move into the call before its preparation; failure and no-op paths release them through ordinary RAII.
Tokio Rust By-value arguments move into the returned future at creation; its body and protocol driving run only when polled. Dropping an unpolled future releases its inputs without asynchronous graceful shutdown.
JavaScript At function-body entry, before fallible options, policy, record or route preparation and before the first await.
Python synchronous runtime At function-body entry, after language argument binding and before fallible preparation or user-defined record iteration.

JavaScript and Python MUST take each distinct available genuine ConnectedPeer resource from the supplied participant positions before other setup validation. The public wrapper then owns no transport. Repeated aliases, already-consumed inputs, and two-connected-peer routes are invalid. A failed invocation MUST release every resource it took exactly once and MUST NOT access resources owned by an incumbent operation. An unavailable argument does not authorize cleanup of its incumbent owner. A failed transport still owned by an available peer is taken before its failure is reported, rather than abandoned during validation. Arbitrary objects with ownership-shaped methods are not connected inputs. Neither is a LocalPeer: it is never taken or consumed and remains reusable.

After transfer, JavaScript/Python peer close() and Python peer context exit are harmless: they neither close nor await the operation that received the resource. An unsubmitted peer still requires its ordinary close. There is no public claim-state query, disposal receipt, or post-transfer IO backdoor. Language argument evaluation or binding may fail before the operation is entered; such failures are outside its acceptance boundary. Local Lace participants remain caller-owned and are retained through their handles. Store-adoption validation order is unchanged.

Preparation or partial construction failure MUST attempt cleanup of every acquired resource, including private participants, codecs, and foreign allocations not yet published in a handle, and finish required owned work. JavaScript awaits failed-setup cleanup before rejecting; Python finishes synchronous cleanup before raising, including for BaseException interruption. Successful open setup publishes the owning handle only after construction succeeds. It does not imply that peer proof or convergence ran. Rust RAII release remains distinct from explicit waitable close. No runtime may use failed setup to submit work to an incumbent or retired machine.

Cancellation and waitable cleanup

After acceptance, callers cancel the operation rather than close its IO directly. Cancellation retires the machine and initiates transport closure immediately, then waits for started store work’s durable classification and position publication before reporting the waitable cancellation boundary. The same ownership covers started authoritative refreshes. That work MUST NOT submit to a retired machine. An interrupted wait MUST NOT release resources still needed by mandatory work; the runtime retains and drains that work before completing its waitable boundary. Cancellation is not a protocol fact, bounded terminal, or resource limit.

Tokio also exposes an owning BoundedInterlace::new(first, second, policy, options), which bounded_interlace uses. Its borrowing run(&mut self) returns the ordinary bounded outcome after required output and route settlement, and the same outcome on later calls. Dropping that future leaves the operation owned, its observations retained and closeable; a later run resumes it, except that interrupted route settlement cannot resume and fails with Integration. Consuming close(self) -> Result<()> cancels the operation, retires transport, and settles started work through durable classification and position publication. It MUST preserve existing operation and independent cleanup failures under the close rules above. Successful close reports settled local cancellation, not convergence; it returns no bounded terminal or observations. Dropping the owner or its close future is a non-waitable fallback.

Dropping a borrowing Tokio event-read future does not drop its owning open operation; dropping a Tokio task handle does not cancel its task. Ignoring a JavaScript promise or collecting a Python object is not waitable cleanup. Python store workers execute owned StoreWork while the machine and position publication remain with the thread driving the operation. These rules add no asyncio API or finalizer.

An open operation can stage a replacement policy

A carrier can carry successive logical sessions, each with an immutable policy and plan. Ordinary open interlace exposes synchronous hard replacement through the same exclusive mutable handle:

Rust/lace-tokio and Rust/lace-rt: open.replace_policy(next_policy)
JavaScript:                     open.replacePolicy(nextPolicy)
Python:                         open.replace_policy(next_policy)

The argument uses the same policy input as opening the interlace. Preparation failure leaves the interlace unchanged. Success means an equal operand was a no-op, the forming prefacing operand was replaced, or the latest replacement was staged for a running owner. Staging performs no polling, stepping, session retirement, store/transport IO, writer-batch mutation, or event consumption/production. It does not wait for activation. Later calls may supersede the staged operand before the next drive step.

For an existing running carrier, at the next poll or step before endpoint work or retained Lace-state delivery, a connected owner retires the complete logical session and re-prefaces on the same carrier. Fresh proof, role-ordered operand announcement, authority, plan, and rounds follow. A peer re-preface makes the same hard cut. Direct replacement stages both prepared operands, the next operand for each bare position and any LocalPeer operand unchanged, and on the next step after both endpoint store effects settle, atomically recreates the pair with unchanged explicit authorities and limits. It does not replace one slot independently.

Replacement cannot run concurrently with event driving, store execution, or another operation on the handle. JavaScript and Python reject it while asynchronous drive/store work owns the handle. After clean closure or cancellation it returns Policy (the interlace is not open); a failed handle returns its retained typed error. A selector-form operation returns Policy: its selector supplies each session’s policy. Porcelain, capture, provider, and observer wrappers keep fixed operation-owned policy and expose no replacement.

Each fixed-point entry has a fresh opaque local identity to reject stale store-watch readiness. Replacement creates no PolicyReplaced event, activation waiter, or policy-identifier event; ordinary lifecycle observations do not identify which staged operand activated. Host-accepted observations remain deliverable history; observations still owned by the retired machine are dropped.

Preparing policies

Lace-130 defines self-contained Interlang compilation and structural composition of complete canonical policies. A source convenience accepts one Lace-120 contribution with local friendly helpers, effects, and datalog: blocks. Applications supply complete source bytes; compilation owns no IO, loader, resolver, or evaluator. Source has no module wrapper, imports, or external relation interface.

Compilation validates all authored rules and dependencies before execution pruning, including unused rules. Each prepared policy MUST have at least one standard 040 facet rule. Empty and helper-only sources are preparation errors; veto-only policies are valid. A side-local executable still requires two ordered operands, their plan, and a typed local slot.

Independent prepared policies combine through 130’s n-ary constraint composer. It isolates private helpers, intersects contributed selection/exposure facets, and unions directional vetoes. An absent facet adds no constraint on that facet; a defined facet with no rows constrains its result to empty. An optional guest collection constrains the result likewise while its rules see only records the authoring inputs expose, and optional field exclusions remove matching records from every composed facet and from that view, as 130 defines. Every input MUST be facet-bearing. Input order and duplicate identical canonical inputs do not change one call’s canonical output. Bindings MUST pass all complete collections to one composition call, not fold a binary operation. Runtime-owned porcelain constraints use the same composer and add no policy mode or third exchange input.

Rust exposes compile, compile_with_limits, and compiler-independent prepare_facet_constraints; JavaScript exposes compile and composePolicies; Python exposes compile and compose_policies. The exact preparation contracts and limits are defined in 130. Helper outputs for generic finite evaluation use the separate canonical 030 Datalog surface, not an Interlang compilation target.

Execution APIs MUST also accept prepared canonical operands without an authoring frontend. Raw canonical input receives ordinary 030/040 validation. Parsed rules may be retained alongside canonical bytes rather than reparsed for unchanged local results. Bindings project one Rust compiler, composer, validator, and evaluator; they MUST NOT implement language-local parsers or composers.

All preparation finishes before prepared participant claiming into a driver or replacement staging. Input acceptance under the connected ownership rules above occurs earlier and is not protocol activation. Failed opening releases accepted inputs; failed replacement leaves its existing operation unchanged. Applying a prepared operand follows hard replacement above. Runtime preparation failures use Policy; source-byte and fixed preparation-bound failures use LimitExceeded. Application loading and lookup failures remain with the application. Preparation results are neither bounded outcomes nor observations.

Diagnostics explain without changing behavior

Tools may explain selection, exposure, route choice, and failure through source maps, rule identifiers, traces, and route reports. Diagnostics are neither normal application events nor protocol facts and do not alter canonical policy text.