@hraness/oh 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +164 -114
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +811 -97
  5. package/dist/errors.d.ts +39 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/graph.d.ts.map +1 -1
  8. package/dist/index.js +676 -61
  9. package/dist/libsql.d.ts.map +1 -1
  10. package/dist/libsql.js +162 -35
  11. package/dist/memory-page.js +2 -2
  12. package/dist/memory.d.ts +87 -6
  13. package/dist/memory.d.ts.map +1 -1
  14. package/dist/memory.js +1106 -148
  15. package/dist/operation.d.ts +3 -1
  16. package/dist/operation.d.ts.map +1 -1
  17. package/dist/projection-public.js +2 -2
  18. package/dist/projection-suss.js +2 -2
  19. package/dist/sdk.js +780 -88
  20. package/dist/semantic-cloud.js +2 -2
  21. package/dist/semantic.js +2 -2
  22. package/dist/sqlite/index.js +1251 -306
  23. package/dist/sqlite/port.d.ts +31 -3
  24. package/dist/sqlite/port.d.ts.map +1 -1
  25. package/dist/sqlite/store.d.ts +18 -2
  26. package/dist/sqlite/store.d.ts.map +1 -1
  27. package/dist/store.d.ts +3 -12
  28. package/dist/store.d.ts.map +1 -1
  29. package/dist/store.js +154 -32
  30. package/dist/sync.d.ts +7 -1
  31. package/dist/sync.d.ts.map +1 -1
  32. package/dist/sync.js +668 -35
  33. package/package.json +5 -1
  34. package/skills/oh/SKILL.md +42 -16
  35. package/spec/README.md +2 -2
  36. package/spec/v1/memory.md +134 -16
  37. package/spec/v1/storage.md +8 -5
  38. package/spec/v1/store.md +20 -0
  39. package/spec/v1/sync.md +77 -8
  40. package/src/cli.test.ts +53 -1
  41. package/src/cli.ts +34 -8
  42. package/src/errors.test.ts +87 -0
  43. package/src/errors.ts +185 -0
  44. package/src/graph.ts +2 -2
  45. package/src/libsql.test.ts +36 -0
  46. package/src/libsql.ts +26 -5
  47. package/src/memory.test.ts +1488 -18
  48. package/src/memory.ts +1199 -122
  49. package/src/operation.ts +13 -3
  50. package/src/sqlite/port.test.ts +209 -0
  51. package/src/sqlite/port.ts +118 -4
  52. package/src/sqlite/store.test.ts +106 -1
  53. package/src/sqlite/store.ts +168 -30
  54. package/src/store.test.ts +12 -0
  55. package/src/store.ts +30 -20
  56. package/src/sync.test.ts +570 -2
  57. package/src/sync.ts +586 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/oh",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "open-source tools for agentic research",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,6 +68,10 @@
68
68
  "types": "./dist/memory-page.d.ts",
69
69
  "import": "./dist/memory-page.js"
70
70
  },
71
+ "./memory": {
72
+ "types": "./dist/memory.d.ts",
73
+ "import": "./dist/memory.js"
74
+ },
71
75
  "./projection": {
72
76
  "types": "./dist/projection-public.d.ts",
73
77
  "import": "./dist/projection-public.js"
@@ -30,8 +30,8 @@ oh --help
30
30
  oh version
31
31
  ```
32
32
 
33
- The supported CLI is the exact npm release `@hraness/oh@0.3.2`. Its identical
34
- tarball and checksum are mirrored by the immutable GitHub Release `v0.3.2`.
33
+ The supported CLI is the exact npm release `@hraness/oh@0.4.0`. Its identical
34
+ tarball and checksum are mirrored by the immutable GitHub Release `v0.4.0`.
35
35
  It requires Bun 1.3.14 or newer. The versioned contract is published at
36
36
  <https://oh.computer/spec/>.
37
37
 
@@ -168,14 +168,17 @@ and credential source. Never print credentials or embed them in records. Oh
168
168
  settles fast-forward histories only; preserve both logs when it reports a
169
169
  divergence.
170
170
 
171
- ## Use composite memory only through host bindings
171
+ ## Use stable composite memory only through host bindings
172
172
 
173
- `@hraness/oh/experimental/memory` is an SDK-only surface. Do not let a model
174
- construct its options. Trusted application code must bind two distinct
175
- authority handles, exact binding digests, a pinned canonical head, working
176
- codecs, a working actor, domain extractor relation ownership and digests,
177
- host-purposed named rule/query programs, and named nomination routes before
178
- giving the returned object to an agent.
173
+ `@hraness/oh/memory` is a stable SDK-only surface. Use
174
+ `createOhMemoryAuthorityV1`, and do not let a model construct its options.
175
+ Trusted application code must bind two distinct authority handles, exact
176
+ binding digests, a pinned canonical head, working codecs, working and adoption
177
+ actors, domain extractor relation ownership and digests, host-purposed named
178
+ rule/query programs, and named nomination routes. Give only the returned
179
+ `authority.agent` object to an agent; retain `authority.host` in trusted
180
+ control-plane code. The old `@hraness/oh/experimental/memory` subpath is a
181
+ compatibility alias, not the preferred import.
179
182
 
180
183
  The agent-facing object may call only `remember`, `query`, `explain`, and
181
184
  `nominate`. Never add a tool parameter for a database path or URL, authority,
@@ -186,18 +189,41 @@ as derived. A nomination may select only a host-registered route and is a
186
189
  prepared dependency-closure candidate for destination-owned review, not
187
190
  permission to write durable knowledge or import the working operation chain.
188
191
 
192
+ Host adoption must pass the complete prepared nomination back through
193
+ `authority.host.adoptNomination` with the exact canonical head it reviewed.
194
+ The host control re-exports the closure from the bound working store, inserts
195
+ absent records in one compare-and-swap operation, and treats equal digests as
196
+ already present. A different digest fails closed unless trusted host code adds
197
+ a bounded `replacements` claim with that exact logical key and exact reviewed
198
+ prior record digest. Never derive that claim from model input or retry it
199
+ against a new head. Missing, stale, wrong, duplicate, and absent-key claims
200
+ abort every change. The host rejects a prospective canonical snapshot over
201
+ 8,192 records or 32 MiB, and reconciles the physical head after the commit so
202
+ an idempotent replay cannot install an older head. Do not suppress its
203
+ structured conflict evidence. Use `advanceCanonical` only for the same pin or
204
+ an exact later head already proven by the bound canonical operation chain.
205
+ Advance more than 16,384 operations in separate reviewed chunks.
206
+
189
207
  Use `createOhMemoryAgentV2` only when the host has registered primitive
190
208
  query-body parameters and fixed all projection, row, page, and page-byte
191
209
  limits. Expose only the exact bindings object, program ID, and continuation to
192
210
  the model. Do not expose parameter declarations, page size, or evaluator
193
211
  options as tool input. Follow `hasMore` until the continuation is `null`, and
194
- restart the named query after an integrity error; never combine pages across a
195
- working-head change. A V2 `query-limit` or `result-bytes` condition is a failed
196
- query, not a partial answer. Treat each continuation as a bearer cursor: pass
197
- it back unchanged only to the exact query and do not log or edit it. If the
198
- host reconstructs the facade or routes across replicas, it must provide the
199
- same private 32 through 64 byte `continuationKey` in host options; never expose
200
- that key as tool input. Keep row-level `proofsTruncated` evidence visible.
212
+ restart the named query only after `OhMemoryContinuationError`; never combine
213
+ pages across a working-head change. Store, projection, and extractor failures
214
+ are not continuation failures and need their own handling. A V2 `query-limit`
215
+ or `result-bytes` condition is a failed query, not a partial answer. Treat each
216
+ continuation as a bearer cursor: pass it back unchanged only to the exact query
217
+ and do not log or edit it. If the host reconstructs the facade or routes across
218
+ replicas, it must provide the same private 32 through 64 byte
219
+ `continuationKey` in host options; never expose that key as tool input. Keep
220
+ row-level `proofsTruncated` evidence visible.
221
+ Explanation capabilities share one 256-entry, 64 MiB cache and one clock guard
222
+ across canonical rollover; do not build a second token router around the
223
+ authority. Pass only plain JSON data to stable methods. Accessors, symbols,
224
+ proxies, sparse arrays, and non-JSON values are rejected before execution.
225
+ The detached-input walk caps depth, per-container breadth, total nodes, and
226
+ canonical bytes before recursively cloning untrusted children.
201
227
 
202
228
  ## Keep memory pages model-neutral
203
229
 
package/spec/README.md CHANGED
@@ -21,7 +21,7 @@ binds these versions:
21
21
  | Hosted semantic cache V1 | `oh.cloudflare.embeddinggemma.v1` |
22
22
  | Hosted semantic cache V2 | `oh.semantic-cloud.v2` |
23
23
  | Projection semantics | `oh.projection.positive-datalog.v1` |
24
- | Composite memory | `experimental v1` |
24
+ | Composite memory | `stable V1 authority over V2 query` |
25
25
  | Memory page | `oh.memory-page.v1` |
26
26
 
27
27
  ## V1 documents
@@ -37,7 +37,7 @@ binds these versions:
37
37
  - [Hosted semantic cache](v1/semantic-cloud.md)
38
38
  - [Isolated hosted semantic cache V2](v2/semantic-cloud.md)
39
39
  - [Derived projections](v1/projection.md)
40
- - [Experimental composite agent memory](v1/memory.md)
40
+ - [Composite agent memory](v1/memory.md)
41
41
  - [Memory pages and `.oh.md` interchange](v1/memory-page.md)
42
42
  - [Compatibility and migration](v1/migration.md)
43
43
 
package/spec/v1/memory.md CHANGED
@@ -1,8 +1,9 @@
1
- # Experimental composite agent memory
1
+ # Composite agent memory
2
2
 
3
- `@hraness/oh/experimental/memory` is the first consumer-facing composition of
4
- the stable store and projection contracts. It is experimental API, not a new
5
- ontology or a third storage authority.
3
+ `@hraness/oh/memory` is the stable consumer-facing composition of the store and
4
+ projection contracts. It is an application authority boundary, not a new
5
+ ontology or a third storage authority. The former
6
+ `@hraness/oh/experimental/memory` path remains an export-compatible alias.
6
7
 
7
8
  ## One kernel, two authorities
8
9
 
@@ -23,9 +24,13 @@ The host also binds the working actor, every named program's purpose, and every
23
24
  named nomination route. None of those authority-bearing labels comes from
24
25
  agent input.
25
26
 
26
- The returned object exposes only `remember`, `query`, `explain`, and
27
- `nominate`. It has no generic commit, store selection, path, sync, rule
28
- registration, canonical write, or purge operation.
27
+ `createOhMemoryAuthorityV1` returns separate `agent` and `host` objects. The
28
+ agent exposes only `remember`, `query`, `explain`, and `nominate`. It has no
29
+ generic commit, store selection, path, sync, rule registration, canonical
30
+ write, adoption, rollover, or purge operation. The host object exposes only
31
+ serialized canonical-head rollover and reviewed nomination adoption. The
32
+ lower-level V1 and V2 agent factories remain available for hosts that already
33
+ own an equivalent control plane.
29
34
 
30
35
  `remember` accepts only an expected working head, semantic puts and tombstones,
31
36
  and an idempotency request ID. The facade supplies its host-bound actor, uses
@@ -70,9 +75,9 @@ silently canonical. Returned result, row, value, proof, source, and receipt
70
75
  graphs are detached and deeply immutable, so a caller cannot mutate bytes after
71
76
  their digest or explanation capability is issued.
72
77
 
73
- ## Additive parameterized pagination (V2 experimental API)
78
+ ## Parameterized pagination (V2)
74
79
 
75
- `createOhMemoryAgentV2` is an additive experimental query surface. It does not
80
+ `createOhMemoryAgentV2` is an additive query surface. It does not
76
81
  change a V1 request, result, digest preimage, factory, or type. Its `remember`
77
82
  and `nominate` methods continue to use the V1 semantic-bundle and nomination
78
83
  contracts. Only its `query` and `explain` envelopes use V2.
@@ -138,8 +143,15 @@ continuation and checks its program, binding, page-size, range, and alignment
138
143
  before reading the working store, invoking extractors, evaluating rules, or
139
144
  mapping proofs. Every valid continued call then rereads the current working
140
145
  head and rebuilds the projection. A head, source, result, or row-count change
141
- fails with an integrity error before proof mapping rather than mixing pages
142
- from two snapshots.
146
+ fails before proof mapping rather than mixing pages from two snapshots.
147
+
148
+ `OhMemoryContinuationError` is the exact public discriminator for a supplied
149
+ cursor that cannot be decoded, authenticated, or rebound to the current exact
150
+ identity. It extends `OhIntegrityError`, carries
151
+ `code: "memory-continuation"`, and classifies `reason` as `encoding`,
152
+ `authentication`, or `identity`. Store verification, projection evaluation,
153
+ extractor, and other runtime failures retain their original error types; the
154
+ facade does not relabel them as continuation failures.
143
155
 
144
156
  An outward result publishes `continuationSha256` beside the opaque token, or
145
157
  `null` beside `null` on the final page. `resultSha256` commits that deterministic
@@ -169,6 +181,110 @@ canonical store, import a derived tuple, grant rights, record a review, or turn
169
181
  a proposed assertion into reviewed knowledge. Destination-owned application
170
182
  code must perform those steps under its own policy and compare-and-swap head.
171
183
 
184
+ ## Stable host control
185
+
186
+ `createOhMemoryAuthorityV1` wraps the V2 agent and binds a separate adoption
187
+ actor. Both host methods accept `unknown`, require exact versioned envelopes,
188
+ and execute serially. A model-facing adapter MUST receive only `authority.agent`;
189
+ it MUST NOT receive `authority.host`, either physical store, or the authority
190
+ factory options.
191
+
192
+ `advanceCanonical` requires the current complete pinned head and a complete
193
+ next head. An equal next head returns an immutable `unchanged` receipt. A later
194
+ head is accepted only after the canonical change feed proves an uninterrupted
195
+ path from the current pin and an exact bounded snapshot reproduces the complete
196
+ next head. The returned `advanced` receipt binds the authority ID, binding
197
+ digest, prior head, and new head. A stale expected head, earlier head, missing
198
+ operation, fork, changed binding, malformed page, or snapshot mismatch fails
199
+ without changing the pin. One call proves at most 16,384 operations and at most
200
+ 64 change-feed pages. The total operation bound is checked before the first
201
+ fetch; hosts MUST advance a longer reachable history in reviewed chunks.
202
+
203
+ Each query captures the current agent instance before its first asynchronous
204
+ read. A rollover therefore cannot mix canonical snapshots into an in-flight
205
+ query. All reconstructed agent generations share one explanation registry,
206
+ byte accounting, wall-clock guard, and monotonic-clock guard. An existing
207
+ explanation capability therefore continues to refer to its original result,
208
+ while the 256-entry and 64 MiB eviction limits and expiry order remain global
209
+ across rollover. Every reconstructed instance also uses the same private
210
+ continuation key. A continuation issued before rollover still authenticates,
211
+ then fails its exact memory-identity check if either source head changed.
212
+
213
+ `adoptNomination` requires an exact expected canonical head and a parsed
214
+ `OhMemoryNominationV1`. It verifies the host-registered nomination route,
215
+ destination purpose, working authority ID, and binding digest. It then asks the
216
+ actual bound working store to re-export the dependency closure at the
217
+ nomination's exact source head and requires byte equality with the proposal.
218
+ This prevents a detached, substituted, or stale capsule from becoming a write
219
+ request merely because its own digest is valid.
220
+
221
+ At the current canonical snapshot, an absent nominated record becomes a put and
222
+ an equal record digest is already present. A different digest remains a strict
223
+ conflict unless trusted host code supplies a `replacements` claim for that exact
224
+ logical key and exact prior canonical record digest. A request may carry at most
225
+ 128 claims. Every claim has the exact keys `expectedPriorRecordSha256`, `key`,
226
+ and `v`; keys must be unique and must name a record in the verified nomination.
227
+ A missing, stale, or wrong claim for a record that still needs replacement is a
228
+ conflict. A claim for an absent canonical key is also a conflict rather than
229
+ permission to insert it. Every supplied claim is validated even when that key
230
+ is already at its nominated digest. An exact replay checks such claims against
231
+ the request's exact reviewed expected head, preserving idempotence without
232
+ silently accepting an invented or stale extra claim. A claim conflict reported
233
+ against an already-equal current record therefore carries equal canonical and
234
+ nominated digests. With no invalid claim, the record remains eligible for the
235
+ existing idempotent `already-present` reconciliation.
236
+
237
+ Any blocking conflict aborts every insert and replacement. Otherwise adoption
238
+ uses a deterministic operation ID derived from the host-bound adoption actor,
239
+ canonical binding, nomination digest, and exact prior complete head. The head
240
+ component preserves exact replay while allowing the same nomination to be
241
+ reviewed and adopted again after a later canonical overwrite or tombstone.
242
+ Before writing, it applies every
243
+ changed nominated record to the pinned snapshot and rejects a result over 8,192
244
+ records or 32 MiB. It then performs one compare-and-swap commit with no merge
245
+ retry. Replacement claims are host-side admission evidence, not new operation
246
+ fields: the existing operation binds the exact parent head and complete changed
247
+ records, so the persisted V1 graph and operation bytes need no new shape. After
248
+ the commit returns, the authority reads and proves the current physical head.
249
+ It installs the returned head only when that is still the physical head; a
250
+ reachable duplicate operation or later write is reconciled against the later
251
+ exact snapshot. A stale expected head or already-ahead physical head may return
252
+ `already-present` only when that current snapshot contains every nominated key
253
+ at its exact nominated digest. This remains one compare-and-swap commit;
254
+ reconciliation never retries it.
255
+
256
+ A host may set `maximumCanonicalOperationBytes` when creating the authority.
257
+ The SQLite commit constructs and hashes the exact operation, then rejects it
258
+ before persistence when its canonical UTF-8 bytes exceed that bound. This lets
259
+ an application guarantee that every admitted canonical operation fits its
260
+ future encrypted transport without shrinking the separate working-memory
261
+ limit. Exact idempotent replays remain subject to the same bound.
262
+ The pre-effect refusal is an `OhOperationSizeError`, a `RangeError` subtype
263
+ that exposes the exact operation bytes and configured maximum so a host can
264
+ distinguish it from an ambiguous post-effect failure.
265
+
266
+ An adoption conflict reports the expected and actual complete heads, total
267
+ conflict count, whether the outward list was truncated, and at most 128 entries
268
+ sorted by key. Each entry carries the nominated digest and the current
269
+ canonical digest or `null` when the key is absent. `conflictsSha256` commits the
270
+ complete sorted conflict set, including entries beyond the outward bound. The
271
+ structured conflict is deeply immutable. Conflict handling and stale
272
+ idempotency never overwrite a canonical record. The optional replacement path
273
+ does not perform last-write-wins reconciliation: only the exact reviewed head
274
+ and exact per-key prior digests authorize the single compare-and-swap.
275
+
276
+ Every `unknown` request on the stable agent and host surfaces is recursively
277
+ detached through enumerable data-property descriptors once at method entry.
278
+ Accessors are never invoked; symbols, non-data properties, sparse arrays,
279
+ non-JSON values, and proxies fail closed. Validation, canonical byte bounds,
280
+ digests, and execution all consume that same frozen detached graph. Bound store
281
+ responses are detached under the same rule before they are parsed or compared.
282
+ Traversal admits at most 128 nested levels, 65,536 entries in one container,
283
+ and 1,048,576 value nodes overall. It counts canonical UTF-8 bytes as it
284
+ traverses and rejects an over-bound array before enumerating or cloning its
285
+ entries; final canonical serialization must reproduce the incremental byte
286
+ count.
287
+
172
288
  ## Memory pages and retrieval
173
289
 
174
290
  A memory page is an application profile for an ordinary `edition` record. Its
@@ -200,11 +316,13 @@ API boundary.
200
316
  The facade requests at most 8,192 records per lane, rejects a lane snapshot
201
317
  over 32 MiB, rejects a remember request over 8 MiB, bounds extractor
202
318
  invocations and emitted facts, limits the public result to 32 MiB, and retains
203
- at most 64 MiB of explanation evidence. A trusted store still constructs the
204
- snapshot before returning it, and a trusted synchronous fact extractor can
205
- consume time or temporary memory before it returns. Provider response limits,
206
- host storage quotas, callback review, isolation, deadlines, and cancellation
207
- remain application responsibilities.
319
+ at most 64 MiB of explanation evidence across every canonical generation. The
320
+ same record and byte ceilings are applied to a prospective adoption snapshot
321
+ before its only compare-and-swap. A trusted store still constructs the snapshot
322
+ before returning it, and a trusted synchronous fact extractor can consume time
323
+ or temporary memory before it returns. Provider response limits, host storage
324
+ quotas, callback review, isolation, deadlines, and cancellation remain
325
+ application responsibilities.
208
326
 
209
327
  Suss is an optional differential evaluator behind the separate projection
210
328
  compatibility subpath. The memory facade uses the package-owned bounded
@@ -50,7 +50,9 @@ application-profile digest, and declared capabilities. A supported runtime
50
50
  MUST reject a later attempt to open the same space under different binding
51
51
  bytes. A working profile disables operation replication and enables only
52
52
  host-controlled whole-space purge. A canonical profile cannot be purged by
53
- that API.
53
+ that API. Its local SQLite authority may instead expose a canonical-only
54
+ replication handle under host control; the promise-based agent store never
55
+ carries that capability.
54
56
 
55
57
  Purge deletes the space head, complete operation history, current records,
56
58
  dependency and operation materializations, sync state, and derived keyword
@@ -78,10 +80,11 @@ before it is returned as current.
78
80
 
79
81
  ## Replay verification
80
82
 
81
- `oh verify` runs SQLite `integrity_check`, parses every canonical operation,
82
- replays the operation chain from an empty graph, recomputes record-set and graph
83
- revision digests, checks dependencies, compares the materialized records, and
84
- requires the reconstructed head to equal the stored head.
83
+ `oh verify` runs SQLite `integrity_check` and `foreign_key_check`, parses every
84
+ canonical operation, replays the operation chain from an empty graph,
85
+ recomputes record-set and graph revision digests, checks dependencies, compares
86
+ the materialized records, operation-record rows, search documents, and FTS rows,
87
+ and requires the reconstructed head to equal the stored head.
85
88
 
86
89
  Backup and restore procedures SHOULD preserve the database and WAL atomically.
87
90
  An application SHOULD run replay verification after an untrusted transfer or
package/spec/v1/store.md CHANGED
@@ -21,6 +21,26 @@ A historical read MUST fail if its sequence is absent or identifies a
21
21
  different operation digest. A change page MUST name its source cursor, pinned
22
22
  through-head, returned cursor, and whether more operations remain.
23
23
 
24
+ A commit may declare `maximumOperationBytes`. The built-in SQLite and direct
25
+ libSQL authorities measure the exact canonical operation before persistence,
26
+ including on an exact operation-ID replay. Exceeding the host-declared bound
27
+ throws `OhOperationSizeError`, a `RangeError` subtype carrying
28
+ `operationBytes` and `maximumOperationBytes`; it never indicates an ambiguous
29
+ post-effect failure. Its public `code` is `oh.operation-size.v1`, and
30
+ `instanceof OhOperationSizeError` remains stable across separately bundled Oh
31
+ entrypoints. Callers validating an unknown caught value may instead use
32
+ `isOhOperationSizeError`; the guard requires a native error with immutable
33
+ branded numeric fields, so copying the public fields onto a plain object is not
34
+ sufficient.
35
+
36
+ The core `OhConflictError`, `OhIntegrityError`, `OhDependencyError`, and
37
+ `OhProfileError` classes likewise preserve `instanceof` identity across
38
+ separately bundled Oh entrypoints. Their corresponding `isOhConflictError`,
39
+ `isOhIntegrityError`, `isOhDependencyError`, and `isOhProfileError` guards
40
+ require a native error with the immutable brand for that exact error family.
41
+ A branded base class remains visible through an ordinary subclass, but a base
42
+ instance MUST NOT satisfy an arbitrary subclass check.
43
+
24
44
  ## Semantic bundle ingress
25
45
 
26
46
  Model-facing code SHOULD use `OhSemanticBundleIngressV1` instead of generic
package/spec/v1/sync.md CHANGED
@@ -25,11 +25,59 @@ interface OhOperationSyncTransportV1 {
25
25
 
26
26
  A bundle binds `oh.sync.v1`, the contract digest, one space ID, at most 1,000
27
27
  ordered operations, and its own digest. Operations MUST form one contiguous
28
- chain. An empty bundle is valid.
28
+ chain. An empty bundle is valid. The complete canonical bundle is limited to
29
+ 64 MiB plus 4 KiB of envelope and operation-separator allowance, and to the
30
+ equivalent finite JSON-node budget. Every operation, including its top-level
31
+ strings, dependencies, and record values, consumes those shared limits; compact
32
+ in-process graphs with repeated references do not multiply into unbounded
33
+ detached data. `OH_SYNC_BUNDLE_MAX_BYTES_V1` exposes the canonical byte limit.
34
+
35
+ The stock synchronizer selects the largest nonempty local prefix that fits
36
+ these shared limits, even when the requested operation-count page is larger.
37
+ The libSQL adapter applies the same cumulative canonical-byte predicate in its
38
+ ordered remote query. A valid history larger than one bundle therefore advances
39
+ in bounded pages instead of becoming permanently unsynchronizable. Host and
40
+ offline exporters can request the same behavior with
41
+ `createOhSyncBundleV1(spaceId, operations, { largestFittingPrefix: true })`.
29
42
 
30
43
  Parsing recomputes every operation digest and the bundle digest. Import also
31
44
  requires the bundle space to equal the selected local space and each operation
32
- to extend the exact local head.
45
+ to extend the exact local head. The SQLite host replication handle applies a
46
+ complete bundle in one `BEGIN IMMEDIATE` transaction. If any later operation
47
+ fails semantic replay, no valid prefix is retained. An exact bundle already on
48
+ the current authority chain is an idempotent replay.
49
+
50
+ `parseOhSyncHeadV1` is the strict ingress for untrusted transport heads. It
51
+ requires only `operationSha256`, `sequence`, and `v`, and enforces that sequence
52
+ zero is the only null-digest head. `parseOhSyncHeadRefV1` applies the same
53
+ descriptor-safe rules to an exact two-field host cursor. Both reject negative
54
+ zero, accessors, proxies, and extra or hidden properties without invoking
55
+ caller code. Bundle parsing checks exact envelopes and cheap collection limits
56
+ before recursively detaching values; it rejects operations that parsing would
57
+ otherwise normalize to different canonical JSON. Record-value detachment is
58
+ limited to 128 nested levels, 1,048,576 traversed value nodes, and the graph
59
+ record's 1 MiB canonical value ceiling. The parser also applies the 64 MiB
60
+ canonical operation ceiling and an equivalent finite operation-node budget.
61
+ Canonical string and container bytes are counted during traversal, and an
62
+ array whose length cannot fit the remaining budget is rejected before its
63
+ entries are enumerated or cloned. Shared object references consume the budget
64
+ again for every canonical occurrence. Dependencies are preflighted under the
65
+ same operation budget before any nested collection is cloned.
66
+
67
+ ## Host-controlled SQLite replication
68
+
69
+ `createOhSqliteStoreAuthorityV1` keeps canonical replication on
70
+ `authority.host.replication`; it is never a method on the agent-safe
71
+ `OhStoreV1`. Working profiles receive no replication handle. The canonical
72
+ handle exposes the exact binding, current head, pinned bundle export, and
73
+ atomic bundle import.
74
+ Import requires an exact strict head reference before parsing the bundle.
75
+
76
+ Pinned export delegates to `changesSince(from, { limit, through })` and returns
77
+ the page's exact `from`, `to`, `through`, and `hasMore` evidence with the
78
+ canonical bundle. A writer that advances after `through` therefore cannot
79
+ silently enlarge the exported interval. Applications SHOULD persist that
80
+ bounded evidence before releasing local custody for network I/O.
33
81
 
34
82
  ## Settlement
35
83
 
@@ -42,9 +90,18 @@ compares heads:
42
90
  - Equal sequences with different digests, or any non-extending chain, are a
43
91
  conflict.
44
92
 
93
+ A pull may not exceed the requested batch size. A page that reaches the
94
+ observed remote sequence must end at that exact operation digest, and is
95
+ rejected before import otherwise. A terminal pull or acknowledged terminal
96
+ push settles in that same round after fresh local- and remote-head checks;
97
+ callers do not need to reserve an extra observation round in `maximumRounds`.
98
+
45
99
  The synchronizer never performs last-write-wins merging. Divergent append-only
46
100
  histories remain intact for an explicit domain merge. Remote acknowledgments
47
- must equal the final pushed operation.
101
+ must equal the final pushed operation. The stock synchronizer is convenient for
102
+ simple transports and keeps the concrete store open while awaiting the network.
103
+ Applications that journal uncertain effects SHOULD instead split capture,
104
+ network exchange, and atomic import into separately revalidated host phases.
48
105
 
49
106
  ## libSQL and Turso seam
50
107
 
@@ -56,12 +113,24 @@ methods compatible with `@libsql/client`. It creates only:
56
113
 
57
114
  The application supplies the client, credentials, endpoint, access policy,
58
115
  retry policy, and backups. The adapter parameterizes values and verifies
59
- canonical operation JSON on pull. Semantic documents and vectors never cross
60
- this seam.
116
+ canonical operation JSON on pull. Pull cursors are nonnegative, pull limits are
117
+ 1 through 1,000, a provider response may not exceed its requested row count,
118
+ each raw operation JSON string is byte-bounded before parsing, and the ordered
119
+ query returns only the largest prefix within the shared bundle byte ceiling.
120
+ Semantic documents and vectors never cross this seam.
121
+
122
+ Push retries are idempotent even if another writer has advanced the remote
123
+ past the submitted tail. The adapter reads at most the submitted row count,
124
+ requires every submitted sequence, digest, and canonical operation JSON to
125
+ match the immutable remote rows, and acknowledges the submitted tail rather
126
+ than the newer remote head. A missing or changed row fails closed.
61
127
 
62
128
  ## Offline transfer
63
129
 
64
130
  `oh sync export --after <sequence> --limit <count>` emits one canonical bundle.
65
- `oh sync import --file <path>` verifies and applies a bundle idempotently. A
66
- transfer process SHOULD preserve the exact bytes and SHOULD run `oh verify`
67
- after the final import.
131
+ `oh sync import --file <path>` verifies and applies the complete bundle
132
+ idempotently in one atomic import; an invalid later operation cannot leave a
133
+ valid prefix behind. Before reading, the CLI requires a regular file no larger
134
+ than the canonical bundle limit plus its one exported terminal LF, and checks
135
+ the actual bytes again after the read. A transfer process SHOULD preserve the
136
+ exact bytes and SHOULD run `oh verify` after the final import.
package/src/cli.test.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
 
7
+ import { canonicalJson, canonicalSha256 } from "./canonical";
8
+ import { createKnowledgeGraphRecordV1 } from "./graph";
9
+ import { createOhOperationV1 } from "./operation";
10
+ import { OhSqliteStore } from "./sqlite/store";
11
+ import { createOhSyncBundleV1, OH_SYNC_BUNDLE_MAX_BYTES_V1 } from "./sync";
12
+
7
13
  const roots: string[] = [];
8
14
  const CLI_PATH = join(import.meta.dir, "cli.ts");
9
15
  const REPOSITORY_ROOT = join(import.meta.dir, "..");
@@ -100,4 +106,50 @@ describe("oh CLI", () => {
100
106
  expect((await run(["contract", "--db", database, "--space", "contract.test"], root)).code).toBe(0);
101
107
  expect(existsSync(database)).toBe(false);
102
108
  });
109
+
110
+ test("imports a sync bundle atomically when a later operation is invalid", async () => {
111
+ const root = await mkdtemp(join(tmpdir(), "oh-cli-atomic-import-test-"));
112
+ roots.push(root);
113
+ const database = join(root, "target.sqlite");
114
+ const bundlePath = join(root, "hostile-bundle.json");
115
+ const source = new OhSqliteStore({ path: ":memory:" });
116
+ const entity = (key: string, name: string) => createKnowledgeGraphRecordV1({
117
+ dependencies: [], key, kind: "entity", v: 1, value: { name },
118
+ });
119
+ source.commit({ actorId: "agent.test", changes: [{ kind: "put",
120
+ record: entity("entity:first", "First"), v: 1 }], expectedHead: source.head(),
121
+ operationId: "op_first" });
122
+ source.commit({ actorId: "agent.test", changes: [{ kind: "put",
123
+ record: entity("entity:second", "Second"), v: 1 }], expectedHead: source.head(),
124
+ operationId: "op_second" });
125
+ const [first, second] = source.exportOperations();
126
+ if (first === undefined || second === undefined) throw new Error("Expected two operations.");
127
+ const { operationSha256: _operationSha256, ...payload } = second;
128
+ const hostileSecond = createOhOperationV1({ ...payload,
129
+ graphRevisionSha256: canonicalSha256("hostile graph revision"),
130
+ recordsSha256: canonicalSha256("hostile records") });
131
+ await writeFile(bundlePath,
132
+ canonicalJson(createOhSyncBundleV1(source.spaceId, [first, hostileSecond])), "utf8");
133
+ source.close();
134
+
135
+ const imported = await run(["sync", "import", "--db", database, "--file", bundlePath]);
136
+ expect(imported.code).toBe(1);
137
+ expect(imported.stderr).toContain("does not reproduce");
138
+ const verified = await run(["verify", "--db", database]);
139
+ expect(verified.code).toBe(0);
140
+ expect(JSON.parse(verified.stdout)).toMatchObject({ operations: 0, records: 0 });
141
+ });
142
+
143
+ test("rejects an oversized sparse sync bundle before opening the database", async () => {
144
+ const root = await mkdtemp(join(tmpdir(), "oh-cli-bounded-import-test-"));
145
+ roots.push(root);
146
+ const database = join(root, "target.sqlite");
147
+ const bundlePath = join(root, "oversized-bundle.json");
148
+ await writeFile(bundlePath, "", "utf8");
149
+ await truncate(bundlePath, OH_SYNC_BUNDLE_MAX_BYTES_V1 + 2);
150
+ const imported = await run(["sync", "import", "--db", database, "--file", bundlePath]);
151
+ expect(imported.code).toBe(1);
152
+ expect(imported.stderr).toContain("regular file of at most");
153
+ expect(existsSync(database)).toBe(false);
154
+ });
103
155
  });
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { readFile } from "node:fs/promises";
2
+ import { lstat, readFile } from "node:fs/promises";
3
3
 
4
4
  import { canonicalJson, opaqueId, safeCode, type JsonValue } from "./canonical";
5
5
  import { OH_CONTRACT_MANIFEST_V1 } from "./contract";
@@ -7,9 +7,9 @@ import { OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, createKnowledgeGraphRecordV1,
7
7
  type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "./graph";
8
8
  import { Oh } from "./sdk";
9
9
  import { OH_SQLITE_SCHEMA_VERSION } from "./sqlite/migrations";
10
- import { createOhSyncBundleV1, parseOhSyncBundleV1 } from "./sync";
10
+ import { createOhSyncBundleV1, OH_SYNC_BUNDLE_MAX_BYTES_V1, parseOhSyncBundleV1 } from "./sync";
11
11
 
12
- export const OH_PACKAGE_VERSION = "0.3.2" as const;
12
+ export const OH_PACKAGE_VERSION = "0.4.0" as const;
13
13
 
14
14
  type ParsedArguments = { options: Map<string, string[]>; positionals: string[] };
15
15
  type ValidatedInvocation = Readonly<{
@@ -174,7 +174,7 @@ async function validateInvocation(command: string, parsed: ParsedArguments): Pro
174
174
  assertAllowedOptions(parsed, [...GLOBAL_OPTIONS, "file"]);
175
175
  const file = one(parsed, "file");
176
176
  if (file === undefined) throw new TypeError("sync import needs --file.");
177
- syncBundle = parseOhSyncBundleV1(JSON.parse(await readFile(file, "utf8")));
177
+ syncBundle = parseOhSyncBundleV1(await readSyncBundleFile(file));
178
178
  if (syncBundle === null) throw new TypeError("Invalid sync bundle.");
179
179
  } else {
180
180
  throw new TypeError("sync needs export or import.");
@@ -191,6 +191,21 @@ async function validateInvocation(command: string, parsed: ParsedArguments): Pro
191
191
 
192
192
  function print(value: unknown): void { process.stdout.write(`${canonicalJson(value)}\n`); }
193
193
 
194
+ async function readSyncBundleFile(path: string): Promise<unknown> {
195
+ // `sync export` writes one terminal LF after the bounded canonical bundle.
196
+ const maximumFileBytes = OH_SYNC_BUNDLE_MAX_BYTES_V1 + 1;
197
+ const metadata = await lstat(path);
198
+ if (!metadata.isFile() || !Number.isSafeInteger(metadata.size)
199
+ || metadata.size > maximumFileBytes) {
200
+ throw new RangeError(`Sync bundle file must be a regular file of at most ${maximumFileBytes} bytes.`);
201
+ }
202
+ const contents = await readFile(path);
203
+ if (contents.byteLength > maximumFileBytes) {
204
+ throw new RangeError(`Sync bundle file must be at most ${maximumFileBytes} bytes.`);
205
+ }
206
+ return JSON.parse(contents.toString("utf8")) as unknown;
207
+ }
208
+
194
209
  const HELP = `oh ${OH_PACKAGE_VERSION}
195
210
 
196
211
  Usage:
@@ -283,14 +298,25 @@ export async function runOhCli(arguments_: readonly string[]): Promise<number> {
283
298
  if (action === "export") {
284
299
  const after = integer(one(parsed, "after"), "after") ?? 0;
285
300
  const limit = integer(one(parsed, "limit"), "limit") ?? 1000;
286
- print(createOhSyncBundleV1(oh.store.spaceId, oh.store.exportOperations(after, limit))); return 0;
301
+ print(createOhSyncBundleV1(oh.store.spaceId, oh.store.exportOperations(after, limit), {
302
+ largestFittingPrefix: true,
303
+ })); return 0;
287
304
  }
288
305
  if (action === "import") {
289
306
  const bundle = validated.syncBundle;
290
307
  if (bundle === null) throw new TypeError("Invalid prepared sync import command.");
291
- let imported = 0;
292
- for (const operation of bundle.operations) if (oh.store.importOperation(operation).imported) imported += 1;
293
- print({ head: oh.head(), imported, v: 1 }); return 0;
308
+ const first = bundle.operations[0];
309
+ if (first === undefined) {
310
+ print({ head: oh.head(), imported: 0, v: 1 }); return 0;
311
+ }
312
+ const imported = oh.store.importOperations({
313
+ expectedHead: {
314
+ operationSha256: first.parentOperationSha256,
315
+ sequence: first.sequence - 1,
316
+ },
317
+ operations: bundle.operations,
318
+ });
319
+ print({ head: imported.head, imported: imported.imported, v: 1 }); return 0;
294
320
  }
295
321
  throw new TypeError("sync needs export or import.");
296
322
  }