khive-changeset 0.5.0

Producer-agnostic KG change-set op-list model and NDJSON-delta serialization
Documentation
# khive-changeset

The KG change-set data model: a producer-agnostic, typed op-list with stage-time-stable
identifiers, and its NDJSON-delta serialization.

A change-set is a durable, inspectable staging artifact between "a producer decided what to
write" and "the write lands in the live graph." A batch producer (an extraction pipeline, an
interactive-agent op recorder, a future bulk-import adapter) emits one, a reviewer can render
it without executing it, and a later stage applies it as a unit. This crate defines the
artifact itself — nothing about how it is validated, tiered, reviewed, or committed lives here.

## What's in the model

- **`ChangeSet`** — an [`Envelope`] plus an ordered `Vec<Op>`. Operation order is semantically
  load-bearing: a `link` op may target the stage-time id an earlier `create` op in the same
  file minted, so order is preserved exactly through serialization.
- **`Envelope`** — change-set-level metadata captured at stage time: producer identity,
  producer model family, a `schema_version`, and an optional `batch_id`. No individual op
  reads it; it exists for a cross-family review gate, commit provenance, and (for `batch_id`)
  the commit trailer to consume downstream. `batch_id` is an opaque, producer-assigned token:
  when a producer supplies one, a commit landing the change-set uses it verbatim as the
  provenance trailer; when absent, the committing tool derives a deterministic fallback from
  `producer` and `staged_at` instead. Absent by default and never serialized (not even as
  `null`) — round-tripping an envelope without one leaves it unset.
- **`Op`** — one of five typed operations, over the same entity/edge/note vocabulary and
  edge-endpoint contract the live request DSL already uses:
  - `Create` — mints a stage-time-stable `Id128` for a new entity or note.
  - `Link` — creates a new edge; `source`/`target` may reference another op's minted id.
  - `Update` — patches an existing entity's, note's, or edge's mutable fields. Carries a
    **required** field-scoped `preimage`: the prior value of exactly the fields the patch
    touches (sets or explicitly clears to null), and nothing else. `UpdateOp`'s fields are
    private; the only ways to build one are the checked `UpdateOp::new` constructor and
    `Deserialize`, and both enforce that the preimage's populated field set matches the
    patch's touched field set exactly — a mismatched pair (a field the patch touches with no
    captured prior value, or a captured prior value for a field the patch leaves unchanged)
    cannot be constructed or deserialized.
  - `Delete` — removes an entity, note, or edge. Carries the full prior record state as a
    **required** field (`preimage`); a `delete` op without one cannot be constructed or
    deserialized.
  - `Merge` — merges two entities. Carries both prior entities and the incident edges the
    merge will rewire as a **required** field, for the same reason.

## NDJSON-delta serialization

`to_ndjson` / `from_ndjson` encode a change-set as one JSON object per line: the envelope as
line 1, then one line per op in stage order. Every line-level type derives
`#[serde(deny_unknown_fields)]`, so a misspelled or extraneous key fails the parse at that
line rather than being silently dropped. This extends to the full-record preimages a `delete`
or `merge` op embeds: `khive_types::{Entity, Note, Link}` accept unknown fields in their own
`Deserialize` impls, so preimages are parsed through crate-private strict mirror structs
(`src/strict.rs`) that add `deny_unknown_fields` before converting into the real types, keeping
the same guarantee without modifying `khive-types` itself. `from_ndjson` also rejects an envelope whose
`schema_version` does not match [`CURRENT_SCHEMA_VERSION`] — an unrecognized version is a
hard error, not a best-effort parse.

This departs deliberately from the whole-graph NDJSON export used elsewhere in this
workspace, which is sorted by primary key for diffability. A change-set's line order is
operation order, not a canonical sort, because operation order carries meaning a snapshot's
row order does not.

The envelope is a header line rather than a sidecar file: a change-set is meant to move as
one artifact between a producer, a reviewer, and an applier, and a header line keeps that
artifact self-contained without a second file that could go missing or drift out of sync.

## Constraints

No filesystem access and no I/O of any kind inside this crate — every function takes an
in-memory value and returns an in-memory value. The crate compiles for
`wasm32-unknown-unknown`; CI additionally executes its test suite under `wasm32-wasip1` (via
a `wasmtime` runner) and asserts pass/fail parity against the native run, since
`wasm32-unknown-unknown` has no standalone test-execution story without extra JS tooling this
repository does not otherwise depend on.

## Where this sits

`khive-changeset` depends only on `khive-types` (entity/edge/note vocabulary — kinds,
relations, and the closed edge-endpoint contract are never redefined here) plus `serde`,
`serde_json`, and `thiserror`. It knows nothing about any producer, any ingester, the rule
evaluator, diff computation, or the CLI — those are separate crates and separate lanes.

Governed by [ADR-101](https://github.com/ohdearquant/khive/blob/main/docs/adr/ADR-101-kg-changeset-model.md)
(change-set model — D1, D2, D5 crate #1) and consumed by
[ADR-102](https://github.com/ohdearquant/khive/blob/main/docs/adr/ADR-102-tiered-validate-and-merge.md)
(tiered validate-and-merge, not implemented by this crate).

## License

Apache-2.0.