@superbee/core 0.2.0-pre.16 → 0.2.0-pre.19

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 (278) hide show
  1. package/README.md +622 -5
  2. package/dist/blob-journal.d.ts +187 -2
  3. package/dist/blob-journal.d.ts.map +1 -1
  4. package/dist/blob-journal.js +377 -5
  5. package/dist/blob-journal.js.map +1 -1
  6. package/dist/body-replace-guards.d.ts +50 -0
  7. package/dist/body-replace-guards.d.ts.map +1 -0
  8. package/dist/body-replace-guards.js +158 -0
  9. package/dist/body-replace-guards.js.map +1 -0
  10. package/dist/builtin-recipes/content.d.ts +93 -0
  11. package/dist/builtin-recipes/content.d.ts.map +1 -0
  12. package/dist/builtin-recipes/content.js +288 -0
  13. package/dist/builtin-recipes/content.js.map +1 -0
  14. package/dist/builtin-recipes.d.ts +10 -0
  15. package/dist/builtin-recipes.d.ts.map +1 -0
  16. package/dist/builtin-recipes.js +97 -0
  17. package/dist/builtin-recipes.js.map +1 -0
  18. package/dist/bundle-health/index.d.ts +14 -0
  19. package/dist/bundle-health/index.d.ts.map +1 -0
  20. package/dist/bundle-health/index.js +12 -0
  21. package/dist/bundle-health/index.js.map +1 -0
  22. package/dist/bundle-health/kind-findings.d.ts +44 -0
  23. package/dist/bundle-health/kind-findings.d.ts.map +1 -0
  24. package/dist/bundle-health/kind-findings.js +68 -0
  25. package/dist/bundle-health/kind-findings.js.map +1 -0
  26. package/dist/bundle-health/legacy-page.d.ts +39 -0
  27. package/dist/bundle-health/legacy-page.d.ts.map +1 -0
  28. package/dist/bundle-health/legacy-page.js +50 -0
  29. package/dist/bundle-health/legacy-page.js.map +1 -0
  30. package/dist/bundle-health/report.d.ts +202 -0
  31. package/dist/bundle-health/report.d.ts.map +1 -0
  32. package/dist/bundle-health/report.js +438 -0
  33. package/dist/bundle-health/report.js.map +1 -0
  34. package/dist/code-point-order.d.ts +8 -0
  35. package/dist/code-point-order.d.ts.map +1 -0
  36. package/dist/code-point-order.js +20 -0
  37. package/dist/code-point-order.js.map +1 -0
  38. package/dist/connectors.d.ts +223 -0
  39. package/dist/connectors.d.ts.map +1 -0
  40. package/dist/connectors.js +1143 -0
  41. package/dist/connectors.js.map +1 -0
  42. package/dist/document-field-actions.d.ts +18 -0
  43. package/dist/document-field-actions.d.ts.map +1 -1
  44. package/dist/document-field-actions.js +91 -29
  45. package/dist/document-field-actions.js.map +1 -1
  46. package/dist/document-mutation.d.ts +7 -9
  47. package/dist/document-mutation.d.ts.map +1 -1
  48. package/dist/document-mutation.js +16 -16
  49. package/dist/document-mutation.js.map +1 -1
  50. package/dist/file-journaled-backend.d.ts +31 -5
  51. package/dist/file-journaled-backend.d.ts.map +1 -1
  52. package/dist/file-journaled-backend.js +131 -118
  53. package/dist/file-journaled-backend.js.map +1 -1
  54. package/dist/hosted-client/blob-delivery.d.ts +6 -0
  55. package/dist/hosted-client/blob-delivery.d.ts.map +1 -1
  56. package/dist/hosted-client/blob-delivery.js +23 -1
  57. package/dist/hosted-client/blob-delivery.js.map +1 -1
  58. package/dist/hosted-client/blob-write.d.ts +386 -0
  59. package/dist/hosted-client/blob-write.d.ts.map +1 -0
  60. package/dist/hosted-client/blob-write.js +770 -0
  61. package/dist/hosted-client/blob-write.js.map +1 -0
  62. package/dist/hosted-client/client.d.ts +64 -0
  63. package/dist/hosted-client/client.d.ts.map +1 -1
  64. package/dist/hosted-client/client.js +31 -0
  65. package/dist/hosted-client/client.js.map +1 -1
  66. package/dist/hosted-client/directory.d.ts +46 -0
  67. package/dist/hosted-client/directory.d.ts.map +1 -0
  68. package/dist/hosted-client/directory.js +74 -0
  69. package/dist/hosted-client/directory.js.map +1 -0
  70. package/dist/hosted-client/errors.d.ts +10 -0
  71. package/dist/hosted-client/errors.d.ts.map +1 -1
  72. package/dist/hosted-client/errors.js +13 -0
  73. package/dist/hosted-client/errors.js.map +1 -1
  74. package/dist/hosted-client/inbox.d.ts +320 -0
  75. package/dist/hosted-client/inbox.d.ts.map +1 -0
  76. package/dist/hosted-client/inbox.js +379 -0
  77. package/dist/hosted-client/inbox.js.map +1 -0
  78. package/dist/hosted-client/index.d.ts +11 -4
  79. package/dist/hosted-client/index.d.ts.map +1 -1
  80. package/dist/hosted-client/index.js +11 -4
  81. package/dist/hosted-client/index.js.map +1 -1
  82. package/dist/hosted-client/journal.d.ts +103 -0
  83. package/dist/hosted-client/journal.d.ts.map +1 -0
  84. package/dist/hosted-client/journal.js +464 -0
  85. package/dist/hosted-client/journal.js.map +1 -0
  86. package/dist/hosted-client/node.d.ts +5 -1
  87. package/dist/hosted-client/node.d.ts.map +1 -1
  88. package/dist/hosted-client/node.js +6 -1
  89. package/dist/hosted-client/node.js.map +1 -1
  90. package/dist/hosted-client/write.d.ts +28 -2
  91. package/dist/hosted-client/write.d.ts.map +1 -1
  92. package/dist/hosted-client/write.js +33 -11
  93. package/dist/hosted-client/write.js.map +1 -1
  94. package/dist/hosted-transport/answer-rows.d.ts +3 -2
  95. package/dist/hosted-transport/answer-rows.d.ts.map +1 -1
  96. package/dist/hosted-transport/answer-rows.js +1 -1
  97. package/dist/hosted-transport/answer-rows.js.map +1 -1
  98. package/dist/hosted-transport/carrier.d.ts +10 -0
  99. package/dist/hosted-transport/carrier.d.ts.map +1 -1
  100. package/dist/hosted-transport/carrier.js +22 -2
  101. package/dist/hosted-transport/carrier.js.map +1 -1
  102. package/dist/hosted-transport/whole-document-transport.d.ts +1 -1
  103. package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -1
  104. package/dist/hosted-transport/whole-document-transport.js +20 -12
  105. package/dist/hosted-transport/whole-document-transport.js.map +1 -1
  106. package/dist/index.d.ts +6 -2
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +4 -2
  109. package/dist/index.js.map +1 -1
  110. package/dist/kind-authoring.d.ts +170 -0
  111. package/dist/kind-authoring.d.ts.map +1 -0
  112. package/dist/kind-authoring.js +451 -0
  113. package/dist/kind-authoring.js.map +1 -0
  114. package/dist/kind-conformance-error.d.ts +15 -0
  115. package/dist/kind-conformance-error.d.ts.map +1 -0
  116. package/dist/kind-conformance-error.js +20 -0
  117. package/dist/kind-conformance-error.js.map +1 -0
  118. package/dist/kinds-load.js +4 -4
  119. package/dist/kinds-load.js.map +1 -1
  120. package/dist/kinds.d.ts +2 -0
  121. package/dist/kinds.d.ts.map +1 -1
  122. package/dist/kinds.js +10 -2
  123. package/dist/kinds.js.map +1 -1
  124. package/dist/paths.d.ts +16 -0
  125. package/dist/paths.d.ts.map +1 -1
  126. package/dist/paths.js +25 -0
  127. package/dist/paths.js.map +1 -1
  128. package/dist/query-filter.d.ts +9 -0
  129. package/dist/query-filter.d.ts.map +1 -1
  130. package/dist/query-filter.js +19 -3
  131. package/dist/query-filter.js.map +1 -1
  132. package/dist/recipe-lifecycle/stamp.d.ts +11 -0
  133. package/dist/recipe-lifecycle/stamp.d.ts.map +1 -1
  134. package/dist/recipe-lifecycle/stamp.js +6 -0
  135. package/dist/recipe-lifecycle/stamp.js.map +1 -1
  136. package/dist/record-overlay/apply.d.ts +43 -0
  137. package/dist/record-overlay/apply.d.ts.map +1 -0
  138. package/dist/record-overlay/apply.js +527 -0
  139. package/dist/record-overlay/apply.js.map +1 -0
  140. package/dist/record-overlay/canonical.d.ts +22 -0
  141. package/dist/record-overlay/canonical.d.ts.map +1 -0
  142. package/dist/record-overlay/canonical.js +47 -0
  143. package/dist/record-overlay/canonical.js.map +1 -0
  144. package/dist/record-overlay/digest.d.ts +4 -0
  145. package/dist/record-overlay/digest.d.ts.map +1 -0
  146. package/dist/record-overlay/digest.js +11 -0
  147. package/dist/record-overlay/digest.js.map +1 -0
  148. package/dist/record-overlay/errors.d.ts +44 -0
  149. package/dist/record-overlay/errors.d.ts.map +1 -0
  150. package/dist/record-overlay/errors.js +55 -0
  151. package/dist/record-overlay/errors.js.map +1 -0
  152. package/dist/record-overlay/findings.d.ts +5 -0
  153. package/dist/record-overlay/findings.d.ts.map +1 -0
  154. package/dist/record-overlay/findings.js +83 -0
  155. package/dist/record-overlay/findings.js.map +1 -0
  156. package/dist/record-overlay/index.d.ts +23 -0
  157. package/dist/record-overlay/index.d.ts.map +1 -0
  158. package/dist/record-overlay/index.js +21 -0
  159. package/dist/record-overlay/index.js.map +1 -0
  160. package/dist/record-overlay/plan.d.ts +12 -0
  161. package/dist/record-overlay/plan.d.ts.map +1 -0
  162. package/dist/record-overlay/plan.js +285 -0
  163. package/dist/record-overlay/plan.js.map +1 -0
  164. package/dist/record-overlay/policy.d.ts +69 -0
  165. package/dist/record-overlay/policy.d.ts.map +1 -0
  166. package/dist/record-overlay/policy.js +228 -0
  167. package/dist/record-overlay/policy.js.map +1 -0
  168. package/dist/record-overlay/redact.d.ts +3 -0
  169. package/dist/record-overlay/redact.d.ts.map +1 -0
  170. package/dist/record-overlay/redact.js +27 -0
  171. package/dist/record-overlay/redact.js.map +1 -0
  172. package/dist/record-overlay/signal-kind.d.ts +30 -0
  173. package/dist/record-overlay/signal-kind.d.ts.map +1 -0
  174. package/dist/record-overlay/signal-kind.js +38 -0
  175. package/dist/record-overlay/signal-kind.js.map +1 -0
  176. package/dist/record-overlay/signal-text.d.ts +45 -0
  177. package/dist/record-overlay/signal-text.d.ts.map +1 -0
  178. package/dist/record-overlay/signal-text.js +116 -0
  179. package/dist/record-overlay/signal-text.js.map +1 -0
  180. package/dist/record-overlay/source-index.d.ts +19 -0
  181. package/dist/record-overlay/source-index.d.ts.map +1 -0
  182. package/dist/record-overlay/source-index.js +102 -0
  183. package/dist/record-overlay/source-index.js.map +1 -0
  184. package/dist/record-overlay/types.d.ts +291 -0
  185. package/dist/record-overlay/types.d.ts.map +1 -0
  186. package/dist/record-overlay/types.js +2 -0
  187. package/dist/record-overlay/types.js.map +1 -0
  188. package/dist/remote-operations.js +5 -2
  189. package/dist/remote-operations.js.map +1 -1
  190. package/dist/search/contract.d.ts +151 -0
  191. package/dist/search/contract.d.ts.map +1 -0
  192. package/dist/search/contract.js +78 -0
  193. package/dist/search/contract.js.map +1 -0
  194. package/dist/search/errors.d.ts +30 -0
  195. package/dist/search/errors.d.ts.map +1 -0
  196. package/dist/search/errors.js +50 -0
  197. package/dist/search/errors.js.map +1 -0
  198. package/dist/search/identity.d.ts +12 -0
  199. package/dist/search/identity.d.ts.map +1 -0
  200. package/dist/search/identity.js +90 -0
  201. package/dist/search/identity.js.map +1 -0
  202. package/dist/search/index.d.ts +15 -0
  203. package/dist/search/index.d.ts.map +1 -0
  204. package/dist/search/index.js +14 -0
  205. package/dist/search/index.js.map +1 -0
  206. package/dist/search/memory-source.d.ts +25 -0
  207. package/dist/search/memory-source.d.ts.map +1 -0
  208. package/dist/search/memory-source.js +230 -0
  209. package/dist/search/memory-source.js.map +1 -0
  210. package/dist/search/rank.d.ts +34 -0
  211. package/dist/search/rank.d.ts.map +1 -0
  212. package/dist/search/rank.js +129 -0
  213. package/dist/search/rank.js.map +1 -0
  214. package/dist/search/row-text.d.ts +38 -0
  215. package/dist/search/row-text.d.ts.map +1 -0
  216. package/dist/search/row-text.js +88 -0
  217. package/dist/search/row-text.js.map +1 -0
  218. package/dist/search/search.d.ts +28 -0
  219. package/dist/search/search.d.ts.map +1 -0
  220. package/dist/search/search.js +240 -0
  221. package/dist/search/search.js.map +1 -0
  222. package/dist/search/source.d.ts +67 -0
  223. package/dist/search/source.d.ts.map +1 -0
  224. package/dist/search/source.js +2 -0
  225. package/dist/search/source.js.map +1 -0
  226. package/dist/search/text.d.ts +47 -0
  227. package/dist/search/text.d.ts.map +1 -0
  228. package/dist/search/text.js +148 -0
  229. package/dist/search/text.js.map +1 -0
  230. package/dist/source-engine/guard.d.ts +82 -0
  231. package/dist/source-engine/guard.d.ts.map +1 -0
  232. package/dist/source-engine/guard.js +396 -0
  233. package/dist/source-engine/guard.js.map +1 -0
  234. package/dist/source-engine/index.d.ts +24 -0
  235. package/dist/source-engine/index.d.ts.map +1 -0
  236. package/dist/source-engine/index.js +20 -0
  237. package/dist/source-engine/index.js.map +1 -0
  238. package/dist/source-engine/machine-state.d.ts +74 -0
  239. package/dist/source-engine/machine-state.d.ts.map +1 -0
  240. package/dist/source-engine/machine-state.js +48 -0
  241. package/dist/source-engine/machine-state.js.map +1 -0
  242. package/dist/source-engine/observe.d.ts +18 -0
  243. package/dist/source-engine/observe.d.ts.map +1 -0
  244. package/dist/source-engine/observe.js +598 -0
  245. package/dist/source-engine/observe.js.map +1 -0
  246. package/dist/source-engine/robots.d.ts +53 -0
  247. package/dist/source-engine/robots.d.ts.map +1 -0
  248. package/dist/source-engine/robots.js +158 -0
  249. package/dist/source-engine/robots.js.map +1 -0
  250. package/dist/source-engine/types.d.ts +285 -0
  251. package/dist/source-engine/types.d.ts.map +1 -0
  252. package/dist/source-engine/types.js +2 -0
  253. package/dist/source-engine/types.js.map +1 -0
  254. package/dist/source-harness/conformance.d.ts +16 -0
  255. package/dist/source-harness/conformance.d.ts.map +1 -0
  256. package/dist/source-harness/conformance.js +265 -0
  257. package/dist/source-harness/conformance.js.map +1 -0
  258. package/dist/source-harness/fixture.d.ts +95 -0
  259. package/dist/source-harness/fixture.d.ts.map +1 -0
  260. package/dist/source-harness/fixture.js +261 -0
  261. package/dist/source-harness/fixture.js.map +1 -0
  262. package/dist/source-harness/index.d.ts +22 -0
  263. package/dist/source-harness/index.d.ts.map +1 -0
  264. package/dist/source-harness/index.js +19 -0
  265. package/dist/source-harness/index.js.map +1 -0
  266. package/dist/source-harness/peer.d.ts +74 -0
  267. package/dist/source-harness/peer.d.ts.map +1 -0
  268. package/dist/source-harness/peer.js +106 -0
  269. package/dist/source-harness/peer.js.map +1 -0
  270. package/dist/storage.d.ts +2 -1
  271. package/dist/storage.d.ts.map +1 -1
  272. package/dist/storage.js +1 -1
  273. package/dist/storage.js.map +1 -1
  274. package/dist/uncertain-write.d.ts +39 -6
  275. package/dist/uncertain-write.d.ts.map +1 -1
  276. package/dist/uncertain-write.js +25 -3
  277. package/dist/uncertain-write.js.map +1 -1
  278. package/package.json +33 -1
package/README.md CHANGED
@@ -173,6 +173,303 @@ target this bundle's edition), `RecipeAssetConflict` (`recipe`, `key`), `RecipeE
173
173
  `RecipeEvolutionInterrupted` (`reason`, `completed`, `pending`, `cause`) and
174
174
  `RecipeEvolutionPostcondition` (`reason`, `completed`).
175
175
 
176
+ ## Record overlay
177
+
178
+ `@superbee/core/record-overlay` is the write half of binding documents to external records. A
179
+ Source Feed policy names the fields a feed owns; the caller fetches and diffs its records and hands
180
+ the changes to the planner. It uses no Node globals or builtins, so it runs in Node, a browser or a
181
+ Worker over any storage backend.
182
+
183
+ ```ts
184
+ import { MemoryBackend } from "@superbee/core/memory-backend";
185
+ import { buildKindRegistry } from "@superbee/core/kinds";
186
+ import { applyRecordOverlay, buildSourceIndex, planRecordOverlay, readOverlayPolicy } from "@superbee/core/record-overlay";
187
+
188
+ const bundle = { root: "memory://guide", backend: new MemoryBackend() };
189
+ const registry = buildKindRegistry(conventionDocs);
190
+ const guide = { bundle, registry, okfVersion: "0.2" as const };
191
+ const policy = readOverlayPolicy(sourceFeedDoc, { targetKind: registry.kinds.get("Event") });
192
+ const overlays = await buildSourceIndex(bundle, { type: policy.target_kind });
193
+ const plan = await planRecordOverlay({ content: guide, ops: guide, policy, changes, observedAt, overlays });
194
+ const receipt = await applyRecordOverlay(plan, { content: guide, ops: guide, policy, actor: "process:calendar-sync", overlays });
195
+ ```
196
+
197
+ For each owned field the plan compares the last accepted feed value, the new feed value and the
198
+ document's value: a document that already says the new value is left alone, one that still says
199
+ the old value is written, and anything else is an `OVERLAY_DOC_CONFLICT` with a Signal, so an
200
+ editor's override is never overwritten. Each step carries the version it was planned against.
201
+ `applyRecordOverlay` writes every step through `mutateDocument` with a guard core builds itself and
202
+ runs on the fresh read: an owned-field write reaches documents of the policy's target Kind only,
203
+ may change only owned and derived fields, never the body, and needs the record's `sources`
204
+ identity; a Signal may be created only as a new, unclaimed document of the attention Kind under its
205
+ path, and updated only in a fixed set of fields plus one appended observation line. Signal writes
206
+ are bound to the provider of the policy the caller passes, never to a field of the plan, and an id
207
+ held by another provider's Signal is refused. A Signal's text must be the planner's rendering of
208
+ its own fields and the run (its observation line, links to the plan's feed and to documents, the
209
+ adoption hint): every plan the planner makes passes, and other text is a held `INVALID_INPUT` row,
210
+ so a mismatch is visible and planned again rather than dropped. Nothing of the
211
+ target Kind is created and nothing is deleted. A feed never owns its target's work state: the
212
+ progress storage fields and the target Kind's claim fields are refused like `status`. The
213
+ attention Kind (default `Signal`) must declare a claim owner and state field and the fields
214
+ `change_kind`, `observed_at`, `provider`, `external_id` and `diff_summary`.
215
+
216
+ Site rules are hooks with safe defaults: `textGate` (identity), `interval` (none, so a disappeared
217
+ record is always raised), `correlate`, `sourceProperties` (an upsert of the record's `sources`
218
+ entry in the same write, OKF v0.2 only), `assertCandidate` (site candidate checks after the core
219
+ guards) and `deriveFields` (fields such as `updated_at` written in the same version). Refusals are
220
+ receipt rows with a `REFUSAL_CODES` code; `RETRYABLE` and `HOLD_STORE` say which leave a record to be
221
+ planned again. A value core's write policy refuses (an OKF v0.2 field type, for example) is a held
222
+ `INVALID_INPUT` row, so one bad record never stops the rest of a run. A malformed plan (an unknown
223
+ step, a non-canonical target, written values that are not JSON data with finite numbers, prototype
224
+ keys, non-scalar or identity `sources` properties), actor or bundle edition throws `OverlayRefusal`
225
+ before any write, and so does a plan with owned-field steps whose policy names no declared target
226
+ Kind or whose owned or derived list names a field a feed can never own.
227
+
228
+ `SourceIndex` holds one head read for a whole run. It is bounded to that run: an edited or deleted
229
+ overlay is still caught at write time, but a second document given an identity during the run is
230
+ only refused as ambiguous on the next run.
231
+
232
+ ## Connectors
233
+
234
+ `@superbee/core/connectors` parses Connector declarations: `type: Connector` documents under
235
+ `connectors/` that say what an integration is, independent of any one target Kind. A declaration
236
+ holds no authority and nothing in the subpath fetches, executes or receives. It uses no Node globals
237
+ or builtins.
238
+
239
+ ```ts
240
+ import { bindSourceFeed, changeBetween, connectorPinDigest, findingsFor, mapRecord, parseConnectorDoc } from "@superbee/core/connectors";
241
+
242
+ const parsed = parseConnectorDoc(connectorDoc, { kinds: registry });
243
+ if (!parsed.ok) throw new Error(parsed.errors.map((e) => `${e.code} ${e.path}`).join("; "));
244
+ const pin = connectorPinDigest(parsed.connector);
245
+ const bound = bindSourceFeed(parsed.connector, sourceFeedDoc, registry, { okfVersion: "0.2" });
246
+ const mapped = mapRecord(bound.source, normalizedRecord, bound.kind);
247
+ if (mapped.kind === "mapped") {
248
+ const change = changeBetween(lastAcceptedAfter, mapped, { external_id: mapped.external_id, change_kind: "changed" });
249
+ }
250
+ const findings = findingsFor(mapped.diagnostics, { source: bound.source.name });
251
+ ```
252
+
253
+ `auth.kind` is `none`, `token`, `github-app`, `oauth2` or `signed-webhook`. A credential's hosts
254
+ come only from core: `token` and `oauth2` name a row of `AUTH_PROVIDERS`, `github-app` is fixed,
255
+ and `credentialHosts` is the one reader. `domains` lists hosts for keyless fetches and may not
256
+ repeat a credential host. Each source names a catalog `profile`, a `target_kind`, mode `overlay`,
257
+ an identity template (`identity.resource`, an https URL whose placeholders are feed parameters, and
258
+ `identity.id` over record paths), `completeness`, and `fields`: target field to a dotted record
259
+ path, or to `{ from, values, unmapped }`. Mapping keys are the most a Source Feed may own; never-owned
260
+ fields, managed metadata, work state, the body and list-typed fields are refused. Actions and events
261
+ are declared shapes only. Every nested block is closed by `CONNECTOR_KEYS`; `parseConnectorDoc`
262
+ returns every refusal with a typed `code` and a JSON pointer `path`. A token shape or URL userinfo
263
+ anywhere in a frontmatter string, key or the body, or a credential key name outside the data-name
264
+ maps, refuses the document alone, naming a path and never the value; so does a YAML alias, which
265
+ keeps every reader bounded by the document's size. The secret rule is a tripwire; the pin, the
266
+ credential store and the fetcher are the boundaries.
267
+
268
+ `connectorPinDigest` covers every key `CONNECTOR_KEYS` accepts, with defaults filled and order-free
269
+ lists sorted, plus the credential hosts core resolves the auth kind to, so a host joining an
270
+ `AUTH_PROVIDERS` row changes the pin. It is blind to the title, description, version, body and
271
+ unknown top-level keys. `CONNECTOR_PIN_RULES` changes whenever a default or sort rule does.
272
+
273
+ `bindSourceFeed` binds a Source Feed (`connector`, `source`, `url`, `owned_fields`) to one source:
274
+ the `url` must equal the identity template rendered from parameters of `[A-Za-z0-9._~-]+` byte for
275
+ byte, `owned_fields` must be mapping keys, and the source's `target_kind`, `completeness` and
276
+ `profile` win. `okfVersion` is the content bundle's edition (absent means v0.1); a feed owning a
277
+ Kind-declared list field binds only on v0.2. Each refusal is an `OverlayRefusal` with
278
+ `POLICY_INVALID`, the feed and the key.
279
+ `mapRecord` never lets a mapping gap erase a stored value: an absent path is unobserved, an
280
+ unmapped native is kept (`unmapped: keep`, the default) or refuses the record while it still counts
281
+ as present (`refuse`), and only an empty value (`null` or blank text) clears an optional field; on a
282
+ required field or `title` it is kept with a finding. An id template separates its placeholders with
283
+ literal text, and a record whose value holds that text, is blank or makes an id past 2048
284
+ characters is refused as `IDENTITY_MISSING` or `IDENTITY_INVALID`, so two records never share one id. `changeBetween` takes the last accepted `after`, carries
285
+ unobserved fields and leaves them out of `changed_fields`. `findingsFor` groups diagnostics per
286
+ source, field, code and value (a list, mapping or empty value by its shape) for `planFindings`, so
287
+ one problem across many records raises one Signal.
288
+
289
+ `examples/recipes/source-feeds` is an example recipe with the `Source Feed` and `Signal` Kinds a
290
+ connector-backed feed uses.
291
+
292
+ ## Source engine
293
+
294
+ `@superbee/core/source-engine` is the portable half of the integration source engine: the one
295
+ loop that observes a bound Source Feed for an author's test, a local run or a managed runner. It
296
+ uses no Node globals or builtins and never calls `fetch`; the caller supplies the network as a
297
+ `Transport`.
298
+
299
+ ```ts
300
+ import { MemoryMachineState, fetchIdentity, observeSource } from "@superbee/core/source-engine";
301
+
302
+ const receipt = await observeSource({
303
+ connector, bound, profile, spec, transport, state: new MemoryMachineState(),
304
+ identity: fetchIdentity("Product/1.0 (+https://example.org/about)")!,
305
+ mode: "incremental", signal: AbortSignal.timeout(60_000),
306
+ commit: async (page) => {
307
+ const plan = await planRecordOverlay({ content, ops, policy: bound.policy, changes: page.changes, observedAt: page.observed_at, records: page.records });
308
+ await applyRecordOverlay(plan, { content, ops, policy: bound.policy, actor: "process:calendar-sync" });
309
+ },
310
+ });
311
+ ```
312
+
313
+ A `SourceProfile` (`id`, `version`, `kind`, `validateSpec`, `hostsFor`, `observe`) turns a bound
314
+ feed, a cursor and a transport into pages of normalized records: `{ records, nextCursor, more,
315
+ complete }`, all JSON data. It never opens a socket, never sees a credential and never writes. A
316
+ `Transport` answers `{ url, method, headers, body, maxBytes, credentialHost, signal }`; it never
317
+ follows a redirect and returns `{ ok: false, refused: "too_large" }` rather than a truncated body.
318
+ A credential-injecting transport adds its credential only when the engine's `credentialHost` is
319
+ true and its own exact match agrees: https, the default port, a host exactly one of the auth
320
+ kind's credential hosts.
321
+
322
+ The profile sees the engine's guarded transport, which checks every URL and every redirect hop:
323
+ https only, the lexical address rule (`refusedAddress`: no IP literal, local name, port or
324
+ userinfo), and a host within the profile's `hostsFor` and the connector's credential hosts plus
325
+ keyless `domains`. A `hostsFor` naming any other host refuses the run before a request. A redirect
326
+ never changes host when either side is a credential host. Keyless hosts are read through robots.txt
327
+ (RFC 9309, the declared identity's product token, the first 512 KiB) first, and a bot-mitigation
328
+ page from one is refused and never retried. Only `GET` is sent unless `rules.post` admits a POST
329
+ toward a credential host; a keyless host is GET only. A GET with a body, a POST redirect, and the
330
+ headers `authorization`, `cookie`, `proxy-authorization`, `host`, `user-agent` and the method
331
+ overrides are always refused; beyond `accept` a header needs `rules.header`, and the validators
332
+ `if-none-match` and `if-modified-since` go out in incremental mode only. The budget counts every
333
+ request, robots.txt and hops included. Each request ends after `requestTimeoutMs` (default 20
334
+ seconds). The deadline is the caller's `signal`; without one the run ends after `deadlineMs`
335
+ (default 5 minutes). The deadline bounds the profile's page too: a page still pending is discarded
336
+ and the run ends `deferred`. A commit or state write in progress is never abandoned, so a hung one
337
+ holds the run. Statuses map to outcomes: 401 and 403 `credential_refused` (no retry), 404 and
338
+ 410 `not_found`, 429 `deferred` with `retry_after`, others `provider_unavailable` (503 included,
339
+ with a retry); `statuses` moves a status between the sets. Any refusal or error status stops the
340
+ run, even when the profile catches the error, and the guarded transport is closed when the run
341
+ ends.
342
+
343
+ A profile runs in the engine's realm. The guard bounds what a declaration and a provider's data can
344
+ reach; profile code itself is bounded by the repository's static egress check
345
+ (`scripts/source-egress-check.mjs`) and by review, until profiles run in an isolate. The check
346
+ refuses globals outside a short list, imports outside an allowlist or the scanned roots, writes to
347
+ anything reached from a global, the prototype, property-definition and descriptor names however
348
+ they are read, `this` outside a class body and CommonJS; a key computed at run time is beyond any
349
+ static check.
350
+
351
+ Each page is mapped with `mapRecord`, diffed against the stored records into `RecordChange` rows
352
+ and handed to `commit`; the cursor and the records are stored only after `commit` resolves. A page
353
+ that promises more with no cursor, or with a cursor the run already asked for, fails the run as
354
+ `cursor_stuck`. `commit` may decline to write changes: a `retry` change (a lost compare-and-swap, a
355
+ claimed Signal) stays pending in the machine state and is planned again at the start of every run,
356
+ before anything is fetched, until it is written, refused for good, or superseded by a newer
357
+ observation of the record (its listing, or its absence from a complete pass); a replay keeps the
358
+ record's missing count, and a record that disappeared is not replayed. A replay whose commit throws
359
+ stays pending with a finding while the run reads on, and is dropped with a finding after
360
+ `MAX_REPLAY_FAILURES` (5). A page that supersedes pending changes stores their removal before its
361
+ commit, so a crash after the commit never replays an older change. A `hold` change is not pending,
362
+ and its record is planned again when it is next observed. Either way the cursor moves on, so one
363
+ record never stops a feed. An incremental
364
+ run continues the stored cursor (`resumed` when the last run left its pass open); a complete run
365
+ lists from the start, sends no validator, and stops on a not-modified answer. A pass is a complete
366
+ observation only when it ran in complete mode to its last page within the run, the profile claimed
367
+ it complete and the source is declared `complete`; only then is a listed record's absence counted,
368
+ and a record is `disappeared` after the feed's `missing_threshold` (default 2) of them. A cursor
369
+ from another profile id or version is never handed back. The receipt names the outcome, every
370
+ request, the pass, the stored cursor, the held and pending counts, the mapping diagnostics and the
371
+ findings for `planFindings`. `MachineState` is the port for the stored state, written with a
372
+ compare-and-swap; `MemoryMachineState` keeps it in memory.
373
+
374
+ ## Source harness
375
+
376
+ `@superbee/core/source-harness` is how a profile and a transport prove the engine's rules offline.
377
+ It returns rows and problem lists rather than asserting, so any test runner can use it, and it uses
378
+ no Node globals or builtins.
379
+
380
+ ```ts
381
+ import { peerTransport, runSourceFixture, runTransportConformance } from "@superbee/core/source-harness";
382
+
383
+ const { problems, missing } = await runSourceFixture(fixture, scenarios);
384
+ const rows = await runTransportConformance({ name: "mine", create: (peer, credential) => myTransport(peer, credential) });
385
+ ```
386
+
387
+ `runSourceScenario` replays a scenario's scripted provider answers (`exchanges`, each used once,
388
+ matched on method and URL; a `hang` answer aborts the run's deadline) through `observeSource`,
389
+ writing each committed page through record-overlay over a MemoryBackend bundle built from the
390
+ fixture's documents, with editor edits between runs. `checkScenario` holds the result to the rule
391
+ of its required scenario (`REQUIRED_SCENARIOS`: provider errors, an incomplete page, cursor resume,
392
+ a hostile cursor refused by the guard, disappearance only from complete observations, owned-field conflicts, unmapped
393
+ and empty values, a missing identity, a cross-host redirect and an undeclared host), and fails any
394
+ request to an undeclared host or one the fixture did not script; a name may add a variant
395
+ (`cursor.resume.page-number`). `missingScenarios` lists what a fixture lacks.
396
+
397
+ `runTransportConformance` is the suite every transport passes against a `ScriptedPeer`, the
398
+ network that records exactly what arrived: the credential reaches its host under its own header
399
+ name (a custom `accessToken` included) and no other host, lookalikes included (a longer or
400
+ shorter name, a trailing dot, userinfo, another port), nor its own host when the engine's
401
+ `credentialHost` is false; a redirect is returned unfollowed, a body
402
+ over the bound is refused while one at the bound is returned whole, and behind the engine a
403
+ redirect to another host or a private address reaches nothing. `peerTransport` is a memory
404
+ transport that passes it.
405
+
406
+ ## Bundle health
407
+
408
+ `@superbee/core/bundle-health` computes the health report behind `superbee status` from a core
409
+ `Bundle` on any backend: malformed documents, Kind lint and conformance debt, registry warnings,
410
+ unresolved links, orphans, freshness and invalid timestamps, trust tiers, the two graph lints, the
411
+ View surface, cross-bundle links, the OKF v0.1 upgrade finding and legacy naming. It uses no Node
412
+ globals or builtins, so it runs in Node, a browser or a Worker. The caller presents the report;
413
+ the CLI adds row caps, help text and its home and sync blocks.
414
+
415
+ ```ts
416
+ import { MemoryBackend } from "@superbee/core/memory-backend";
417
+ import { bundleHealth } from "@superbee/core/bundle-health";
418
+
419
+ const bundle = { root: "memory://guide", backend: new MemoryBackend() };
420
+ const report = await bundleHealth(bundle, { now: new Date() });
421
+ const gate = { malformed: report.malformed.length, kind_warnings: report.kindLint.length, unresolved_links: report.unresolvedLinks.length };
422
+ ```
423
+
424
+ The report reads only: one Kind registry load, one lenient query, two prefix-scoped blob listings
425
+ and one root-index read, with every finding derived in memory. A document whose frontmatter does
426
+ not parse is a `malformed` row (the root index first), never a thrown error. Top-level keys are
427
+ camelCase; rows keep snake_case keys (`age_ms`, `bundle_id`, `incompatible_values`) and arrive
428
+ whole, in report order, for the caller to cap. Sections whose presence depends on the bundle are
429
+ null when absent: `conformanceDebt` without any declared Kind, `trust` outside a non-empty v0.2
430
+ bundle, `crossBundle` without `docs/dependencies` or any `okf:` link, `okfUpgrade` outside a v0.1
431
+ bundle with a workflow-status collision. `crossLinkTypeViolations` is always an array, because a
432
+ local edge under a qualified declaration needs no dependencies. The stored values in
433
+ `invalidStaleAfter`, `invalidTimestamps` and `invalidLegacyTimestamps` are made JSON-safe;
434
+ `missingExpectedLinks[].status` is the raw stored value.
435
+
436
+ Nothing outside the bundle is read unless the caller binds it. `bindings` is a map of bound copies
437
+ by alias, or a hook core calls exactly once, after every read and with its own parse of
438
+ `docs/dependencies` (a self alias already dropped as `DEP_SELF`, using `selfBundleId` when
439
+ `docs/bundle` declares no id). Whatever the hook throws propagates unchanged. Each binding's
440
+ verdict comes from core's resolver, and `dependencies[].bound` is the binding's `label`.
441
+
442
+ `bundleKindFindings(documents, okfVersion, { actor, producer })` answers a different question:
443
+ which Kind problems a strict host write would refuse, preparing each document as its first edit,
444
+ so a timestamp or actor the write supplies is no finding. The CLI's publish preview runs it; the
445
+ report's `kindLint` stays advisory. Both use core's one validator, `validateAgainstKind`.
446
+
447
+ ## Search
448
+
449
+ `@superbee/core/search` is the one `documents.search.v1` engine: the request and result contract,
450
+ query normalization, ranking (`portal-webmcp-search/v1`), the any-term fallback, facets, cursors
451
+ and page budgets. A backend supplies what it searches through `SearchSource`: one `evidence()`
452
+ call per request and `text()` for the page's excerpts. `memorySearchSource` indexes a complete
453
+ corpus in memory. The subpath uses no Node globals or builtins.
454
+
455
+ ```ts
456
+ import { memorySearchSource, searchDocuments } from "@superbee/core/search";
457
+
458
+ const source = memorySearchSource(documents, { key: headsDigest });
459
+ const result = await searchDocuments(source, { query: "lock keeper", limit: 10 }, { scope: bundleId });
460
+ ```
461
+
462
+ `key` identifies the corpus; a cursor is refused for another key, scope, query, match or filters
463
+ (`type`, `tag`, `prefix`). A request outside the contract throws `SearchInputError`; a corpus over a
464
+ bound throws `SearchLimitError`; a request whose terms hold more than `maxEvidenceInstances` capped
465
+ instances (a `searchDocuments` option, at most `SEARCH_LIMITS.maxEvidenceInstances`) throws
466
+ `SearchBroadQueryError`, a `SearchLimitError` whose message asks for fewer or more specific terms.
467
+ A source must hold one document per id.
468
+ A document over 65,536 body terms is left out and counted: the result's `corpus` reports
469
+ `documents`, `indexed`, `skipped` and `reindexPending`. `searchRowText` is what one document
470
+ contributes to any index, and `textVersion()` names the algorithm, `SEARCH_TEXT_REVISION` and the
471
+ runtime's normalizer fingerprint, so a stored index can tell when its rows need re-deriving.
472
+
176
473
  ## Hosted client
177
474
 
178
475
  `@superbee/core/hosted-client` is the headless client of a hosted Superbee's sync routes
@@ -256,14 +553,20 @@ Each write has one request identity and carries the client's binding, a digest o
256
553
  client mints once (its `within` clients share it). When an answer is lost (a transport failure or
257
554
  a deadline after sending), the client looks the identity up on the host and reports what the host
258
555
  recorded. The host's outcome route tells a request it never received ("absent") from one still in
259
- progress ("pending"), but the shared transport reports both as "no outcome yet". On that answer
260
- `write` sends the same identity once more, which the host applies at most once. Absence counts as
556
+ progress ("pending"), and the shared transport keeps the two apart. On either answer `write`
557
+ sends the same identity once more, which the host applies at most once; after "pending", an
558
+ earlier delivery is known to be held. Absence counts as
261
559
  evidence only within the retention window the host serves in its capabilities
262
560
  (`operationsRetentionMs`, less an hour's margin); past it, the result stays `unknown` and nothing
263
561
  is resent. The client measures an intent's age by its own clock against the host's window, so that
264
562
  margin holds only while the client's clock is within an hour of the host's. A resend refused before
265
563
  it reaches the host's execution (no credential, access withdrawn, a spent quota) leaves the result
266
- `unknown` too, since the first delivery may still commit. When the lookups themselves fail, or the caller's `signal` aborts after sending, the
564
+ `unknown` too, since the first delivery may still commit; after a "pending" lookup, so does any
565
+ refusal before execution, a gateway's `400` included. The result's `refusal` (`{ code, message }`)
566
+ names that refusal, so an app can say why the write is waiting (for example, access withdrawn)
567
+ while it stays held; only the call that met the refusal reports it. Absence alone never settles
568
+ such a write: a delayed first delivery is authorized when it arrives, so it can still commit once
569
+ access returns. When the lookups themselves fail, or the caller's `signal` aborts after sending, the
267
570
  result is `unknown`.
268
571
 
269
572
  An `unknown` write can be resolved later in two ways:
@@ -271,7 +574,7 @@ An `unknown` write can be resolved later in two ways:
271
574
  - `lookupWrite(requestId)` makes one outcome lookup and never sends the write. When the host holds
272
575
  no outcome yet (absent or pending), the answer stays `unknown`.
273
576
  - `settleWrite(requestId)` looks the identity up and, when the host never recorded it within its
274
- retention window, sends the same identity once more, as `write` would have.
577
+ retention window or holds it pending, sends the same identity once more, as `write` would have.
275
578
 
276
579
  Both answer `{ status: "unrecorded", requestId }` when the intent store holds no write under that
277
580
  identity: it was never recorded there, or it already settled and left the store. Both return
@@ -285,7 +588,8 @@ Until it settles, a write is held in an intent store: its operation, bundle (and
285
588
  document id, document and digest, expected version, request identity and binding, which is
286
589
  everything needed to rebuild the lookup exactly. The default store is in memory and lives as long
287
590
  as the client. Pass `intentStore` (`put`, `get`, `delete`, and optionally `list`) to keep intents
288
- durable across restarts; lookups use the binding recorded with each intent. A service that
591
+ durable across restarts (`openHostedClientJournal`, below, is one for Node); lookups use the
592
+ binding recorded with each intent. A service that
289
593
  restarts with a durable store calls `heldWrites()` to find the writes that never settled
290
594
  (including any recorded but never sent), then settles each with `settleWrite`. `heldWrites()`
291
595
  throws `HostedIntentsNotListable` when the store has no `list`. A settled write leaves the store.
@@ -299,6 +603,254 @@ document (`invalid_input`). Calls on one client that name the same identity run
299
603
  its capabilities read, or a failure of the intent store to record the write. Document deletion is
300
604
  not part of `write`.
301
605
 
606
+ ### Writing a blob
607
+
608
+ `writeBlob` and `deleteBlob` write one ordinary file (a blob: an image, a diagram, any bytes) to a
609
+ hosted bundle. A create names an absent key; a replace and a delete name the head they expect,
610
+ its version **and** content type (a `BlobPin`), so a type-only change is a replace too.
611
+
612
+ ```ts
613
+ const svg = new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>");
614
+ const created = await client.writeBlob("notes", { key: "diagrams/flow.svg", bytes: svg, contentType: "image/svg+xml", expectAbsent: true });
615
+ if (created.status !== "committed") throw new Error(created.status);
616
+ let pin = created.head!; // { key, version, contentType }
617
+
618
+ const replaced = await client.writeBlob("notes", { key: pin.key, bytes: edited, contentType: "image/svg+xml", expected: pin });
619
+ if (replaced.status === "committed") pin = replaced.head!;
620
+
621
+ const read = await client.readBlob("notes", pin.key, { pin }); // bytes verified, or null if no longer at the pin
622
+ const deleted = await client.deleteBlob("notes", { key: pin.key, expected: pin });
623
+ ```
624
+
625
+ Each call returns one of:
626
+
627
+ - `{ status: "committed", head, changed? }`: applied. `head` is the new head (null after a
628
+ delete); `changed` is false for a replace the head already matched.
629
+ - `{ status: "conflict", reason: "version_conflict", actual }`: the head is not at the expected pin
630
+ (for a create: the key exists). `actual` is the head now, or null when there is none (a delete of
631
+ an absent key). Final for that identity.
632
+ - `{ status: "conflict", reason: "concurrent_change", message }`: another write overlapped this one
633
+ past the host's own retries. Nothing was applied.
634
+ - `{ status: "refused", code, message }`: the host's own refusal, or one the client names. The
635
+ host's codes are those it records under the identity (`backend_unavailable`, ...) and those it
636
+ answers before dispatch and never records (`insufficient_scope` for a grant without blob writes,
637
+ `bundle_not_found`, and `request_capacity` in a `200` or a `429`). A refusal of the second kind
638
+ proves only that this attempt did not run, so it is final only when every earlier attempt of the
639
+ write is proven not applied, which is always so for a write the client sends; a write whose
640
+ earlier attempt was unclear is never sent again, only looked up, and stays `unknown`.
641
+ `insufficient_scope`, `bundle_not_found` and `invalid_input` also drop the held capabilities
642
+ answer, so the next write reads them again. The client's own codes are `invalid_input` (an
643
+ unsafe key, a key under one of Superbee's own record folders or a reserved file, an invalid content type, a body over the host's `blobWriteBytes` or 16 MiB, or a held
644
+ identity reused for another write), `scope_mismatch` (a held write, or a reused `requestId`, recorded
645
+ for another origin or sync prefix) or `unsettled_write` (an earlier write of the same key in the bundle is still
646
+ unsettled: settle or forget it first). Nothing was sent for a client-named refusal.
647
+ - `{ status: "held", deletions, baseline }`: the host holds this mass delete (`428
648
+ deletions_held`). Nothing was applied. A person confirms the count, and
649
+ `settleBlobWrite(requestId, { acceptDeletes })` with at least `deletions` sends it again; without
650
+ `acceptDeletes` it stays held and nothing is sent, and with fewer it throws
651
+ `HostedBlobDeletionsNotAccepted`, sending nothing. `deleteBlob(..., { acceptDeletes })` sends the
652
+ confirmation with the first attempt.
653
+ - `{ status: "unknown" }`: the host may or may not hold the write.
654
+
655
+ Every result names its `requestId`, `key` and `operation`.
656
+
657
+ **The capability gate.** A blob write needs the bundle's `blobWrites: "allowed"` and its
658
+ `blobWriteBytes` bound, from the same held capabilities answer `write` uses. When the host serves
659
+ the bundle no blob writes (an older host's answer reads as refused), `writeBlob` and `deleteBlob`
660
+ throw `HostedBlobWritesNotServed`, a `HostedRouteNotServed` (`isRouteNotServed` is true), before
661
+ anything is journaled or sent. This differs from `write()`, which answers a bundle without writes
662
+ with the result `refused: read_only`: the blob routes are a newer capability a caller probes for, as
663
+ it probes for any route a host may lack. The checks a send would otherwise fail (the capability, the
664
+ byte bound, the key and the content type) all run against that one answer before the write is
665
+ journaled, and the delivery is handed the same answer, so the two cannot disagree.
666
+
667
+ **Exactly once, never resent after it may have left.** Each blob write is journaled under one
668
+ request identity (with the client's binding, the bundle and workspace and the bytes), durably
669
+ claimed, then sent once. After an unclear answer the client looks the identity up on the host
670
+ (`/outcome`, under the binding recorded with the write) and reports what the host recorded. Unlike a
671
+ document write, an `absent` lookup is never permission to send again: once an attempt may have left,
672
+ a blob write is only ever looked up. A send proven never to have left (the credential provider
673
+ failed or was denied, the signal was already aborted, the identity was malformed, a local check
674
+ failed) is rolled back instead: `writeBlob` throws `HostedBlobWriteNotSent` with its `requestId` and
675
+ `reason`, and the write stays held and resendable (`held: true`), so `settleBlobWrite` or the same
676
+ `writeBlob` call (same `requestId`, same input) sends it. A transport failure after the request was
677
+ handed to the carrier is `unknown`.
678
+
679
+ **Recovery.** `heldBlobWrites()` lists every unsettled blob write (`requestId`, `bundleId`,
680
+ `workspace`, `key`, `operation`, `state`, `attempts`, `resendable`, `hold`, `createdAt`) and sweeps any
681
+ settled one a crash left behind.
682
+
683
+ - `lookupBlobWrite(requestId)` makes one outcome lookup and never sends. A write never sent (or
684
+ proven not applied) answers `{ status: "pending" }` without a request, and a held delete `held`.
685
+ - `settleBlobWrite(requestId, { acceptDeletes? })` submits the write when its state is pending or
686
+ unknown and every attempt is proven not applied (`attempts === notAppliedThrough`); otherwise it
687
+ only looks it up, and an absent lookup is `unknown`.
688
+ - `forgetBlobWrite(requestId)` drops an `unknown` write once one fresh lookup answers `absent`,
689
+ returning `{ status: "forgotten" }`. **The earlier delivery may still commit**; the host's
690
+ compare-and-swap is what keeps a later write from losing that update. Any other record is
691
+ `{ status: "kept", reason: "not_unknown" }` (settle an `in_flight` one first), a lookup that fails,
692
+ is refused or is malformed is `kept` with `lookup_unavailable`, and a lookup that finds the outcome
693
+ settles the write instead. A forgotten identity is no longer held, so `writeBlob` with that same
694
+ `requestId` journals and uploads it again under that identity. That is safe: the host answers a
695
+ delivery it recorded under the identity with the recorded result, and one it did not record is
696
+ checked against the expected pin like any write.
697
+
698
+ All three answer `{ status: "unrecorded", requestId }` when the journal holds no blob write under the
699
+ identity. A reused `requestId` with the same input resumes (settles) the held write; with different
700
+ input it is `refused: invalid_input` and nothing is admitted. Calls are serialized per request
701
+ identity and per key (the journal's guard covers a key across bundles), so concurrent calls of one
702
+ client never trip each other.
703
+
704
+ ```ts
705
+ // A service restarting after a crash: settle what it held, forgetting only proven-absent unknowns.
706
+ for (const held of await client.heldBlobWrites()) {
707
+ const settled = await client.settleBlobWrite(held.requestId);
708
+ if (settled.status === "unknown") console.warn(`blob write ${held.requestId} is still unknown`); // forgetBlobWrite once you choose to move on
709
+ }
710
+ ```
711
+
712
+ **Reads.** `readBlob(bundleId, key, { pin })` requires the pin: it reads exactly that version and
713
+ content type, verifies length, digest and type, and answers null when the key is no longer at the pin
714
+ (the host's `409`). `blobHead(bundleId, key)` is the explicit lookup of one head (or null), and
715
+ `blobHeads(bundleId)` the whole listing; both read the full verified listing (up to 10,000 heads in
716
+ 4 MiB pages), since the host has no single-key head route yet.
717
+
718
+ **Keys.** `isOrdinaryBlobKey` is the one key policy every blob writer uses, this client and the
719
+ CLI's checkout sync alike: a safe key that is not a reserved file (`index.md`, `log.md`) and does not
720
+ sit under Superbee's own record folders (`views/`, `pages/`, their registries, `conventions/`,
721
+ `recipes/`, `references/`, compared case-insensitively). Any other key is refused `invalid_input`
722
+ before any request.
723
+
724
+ **Aborts.** A `signal` aborted before the write is admitted throws its reason and journals nothing;
725
+ that includes an abort while the capabilities are still being read, so a hung credential provider
726
+ never holds the call (or its key) past the caller's abort. Once admitted, a write is held until it
727
+ settles, and an abort before its send leaves it resendable (`HostedBlobWriteNotSent`).
728
+
729
+ **Journal.** The default blob journal is in memory (`createMemoryBlobJournal`), owned by the client.
730
+ Give a blob journal to one client (its `within` clients share it). Two clients over one journal
731
+ coordinate only through its per-key guard: concurrent writes of different identities still settle,
732
+ but two calls on the same request identity from both clients can fail with a raw journal conflict.
733
+ Pass `blobJournal` to keep blob writes across restarts: in Node, `openHostedClientJournal(...).blobs`
734
+ (below). `driveJournaledBlob(journal, context, requestId, mode)` is the owner of the journaled
735
+ sequence (admit, claim, deliver, persist, retire) for a caller with its own journal and premises; its
736
+ `premises.snapshot(key, step)` returns each transaction's guard plus the caller's own metadata
737
+ changes, checked atomically with it.
738
+
739
+ **Limitations.**
740
+
741
+ - Lookups currently need `blobWrites`: a grant downgraded to read-only cannot settle its earlier
742
+ blob writes until the host authorizes the outcome route on its own (a hosted follow-up).
743
+ - A lookup across principals is unverified: a new token of the same principal finds its outcomes
744
+ (the binding travels in the journal), but what another principal's lookup gets is not yet proven.
745
+ - Every journal record of a write carries its bytes, and each transaction validates the record.
746
+ Validation is cached per payload, so a write costs about 0.17 s per MiB in Node (3 MiB about
747
+ 0.5 s, 16 MiB about 2.6 s, measured with the in-memory journal); without the cache it was about
748
+ 1.7 s per MiB (16 MiB about 28 s). The first validation of a payload still runs on the caller's
749
+ thread in one slice, about 1.2 s for 16 MiB.
750
+
751
+ ### Inbox
752
+
753
+ `createHostedInbox(client, { tenantId })` is a person's hosted inbox in one workspace, over the
754
+ same client (its credential, deadline and abort; a `within` client serves too). It reaches the
755
+ attention routes, `POST <prefix>/attention/<operationId>`, and names the workspace in the
756
+ `Superbee-Workspace` header on every request, since a sync sign-in spans all of the person's
757
+ workspaces. Notifications are a hosted capability: a host without these routes answers
758
+ `HostedRouteNotServed`, and nothing here emulates a local inbox.
759
+
760
+ ```ts
761
+ import { createHostedInbox } from "@superbee/core/hosted-client";
762
+
763
+ const identity = await client.whoami();
764
+ const inbox = createHostedInbox(client, { tenantId: identity.tenantIds[0]!, via: "my-agent" });
765
+ const receipt = await inbox.request({ recipient: { kind: "principal", principalId: identity.principalId }, kind: "needs_input", note: "Ready for you" });
766
+ const page = await inbox.list({ threadId: receipt.threadId, afterSeq: 0 });
767
+ ```
768
+
769
+ The methods are `list`, `threads`, `summary`, `get` (does not mark read), `open` (marks read),
770
+ `ack` (at the revision read), `request` (send) and `cancel` (withdraw your own send), with the
771
+ open contract's shapes (`InboxEntry`, `InboxThread`, `AttentionReceipt` and the inputs). A
772
+ recipient is a principal id; to notify yourself, use `whoami().principalId`. A document target
773
+ names `whoami().targetAuthorityKey` as its `authorityKey` and the document's current version; a
774
+ host that reports no key (the field is absent, as on every `/sync/v1` host today) cannot take one yet.
775
+
776
+ `request` mints one UUIDv7 ticket per send (or takes the one you pass). When an answer is lost or
777
+ the host marks its refusal retryable, it sends the same body under the same ticket again
778
+ (`sendAttempts`, default 3), which the host answers as a replay of the first send, never as a second
779
+ item. A send still unclear after that throws `HostedAttentionOutcomeUnknown` with its `ticket`:
780
+ `request({ ...input, ticket })` within 24 hours settles it. Refusals are
781
+ `HostedAttentionRefusal` (a `RemoteError`) with the catalog's `code` (`attention_disabled`,
782
+ `not_found`, `target_unavailable`, `target_unsupported`, `quota_exceeded`, `request_conflict`,
783
+ `version_conflict`, `ticket_expired` with `serverTime`, `workspace_not_available` and the rest),
784
+ `retryable`, and a message in this client's words: the host's message and hint are never repeated.
785
+
786
+ ### Durable intent store (Node only)
787
+
788
+ `openHostedClientJournal` from `@superbee/core/hosted-client/node` is a durable `intentStore`, and its
789
+ `blobs` a durable `blobJournal`, so a short-lived process that exits with a write `unknown` can recover
790
+ it after a restart.
791
+
792
+ ```ts
793
+ import { createHostedClient } from "@superbee/core/hosted-client";
794
+ import { openHostedClientJournal } from "@superbee/core/hosted-client/node";
795
+
796
+ const journal = await openHostedClientJournal({ directory: path.join(stateDir, "superbee-writes") });
797
+ const client = createHostedClient({ target, credentials, intentStore: journal, blobJournal: journal.blobs });
798
+ for (const held of await client.heldWrites()) await client.settleWrite(held.requestId);
799
+ for (const held of await client.heldBlobWrites()) await client.settleBlobWrite(held.requestId);
800
+ // ... writes ...
801
+ await journal.close();
802
+ ```
803
+
804
+ The journal is a `FileJournaledBackend` directory: an fsynced, checksummed log with snapshot
805
+ compaction. Its on-disk format is a supported compatibility promise: a format bump ships a reader
806
+ for every earlier layout, and an older reader refuses a newer store with `FileJournalFormatError`
807
+ rather than misreading it. The current format is 3 (see `JOURNALED-BACKENDS.md`). Each unsettled write is one metadata row, `hosted-client.write.<requestId>`,
808
+ holding the whole document and the binding it was sent under, so a new process with a new token
809
+ looks it up exactly as the first would have. A settled write is removed. Each unsettled blob write is
810
+ one row of the backend's own blob intent family, with its bytes, under the same transition rules as
811
+ the in-memory journal; a settled one is retired, and since every record of a blob write carries its
812
+ bytes, retiring or forgetting one compacts the store once its log or snapshot is past 1 MiB. Metadata
813
+ a blob write carries may not name the journal's own rows (`hosted-client.*`) or a checkout's
814
+ blob-sync state.
815
+
816
+ - **Ownership.** Open takes an exclusive same-user lock on the directory until `close()`. Another
817
+ process waits `lock.waitMs` (default 5,000 ms) and then gets `FilesystemMutationLockError`. A
818
+ lock left by a process that died on this host is reclaimed.
819
+ - **Local file system only.** The lock lives in a per-user runtime directory on this host, so it
820
+ cannot exclude a process on another host. Keep the journal on a local disk, never on NFS, SMB
821
+ or another shared mount. Open does not detect a network mount; this is the operator's rule.
822
+ - **Privacy.** The journal holds whole documents. A directory open creates is mode 0700, and its
823
+ files 0600. On POSIX, an existing directory that is a symbolic link, is owned by another user,
824
+ or grants its group or others any permission, is refused as `HostedClientJournalError`
825
+ `JOURNAL_DIRECTORY_NOT_PRIVATE`. The check runs again once the lock is held, and a directory
826
+ replaced in between is refused too. The directory is the journal's alone: an existing one may
827
+ hold only `store.log`, `store.snapshot` and `store.snapshot.tmp`, and any other entry (an
828
+ operating system's `.DS_Store` included) is refused as `JOURNAL_NOT_HOSTED_CLIENT`, as is a
829
+ `store.log` that is not a journal's log, and a store file that is a symbolic link, a
830
+ directory, has more than one link, or (on POSIX) belongs to another user. The listing and the
831
+ files are checked again under the lock. Another process of the same user changing the
832
+ directory while it opens is out of scope: in that window the open can truncate a torn or
833
+ foreign log tail.
834
+ - **Marker.** A new journal records `hosted-client.journal = { schemaVersion: 1 }` in its first
835
+ transaction. Open refuses, as `JOURNAL_NOT_HOSTED_CLIENT`, a store with rows but no marker or one
836
+ holding documents, blob files or a checkout's blob-sync state, so it is never pointed at a CLI
837
+ checkout store. A marked journal holding blob write intents is its own and reopens. A marker of another schema is `JOURNAL_SCHEMA_UNSUPPORTED`. Open decides on a read-only
838
+ pass first, so a refused directory's files are left byte for byte. A crash before the marker
839
+ lands leaves an empty store, which the next open marks.
840
+ - **Size.** Once the store is adopted, the log is compacted into the snapshot whenever it grows past the greater of 1 MiB
841
+ (`HOSTED_CLIENT_JOURNAL_COMPACT_FLOOR_BYTES`) and twice the last snapshot, so steady writing keeps
842
+ it near 1 MiB. The snapshot, which holds every write still unsettled, and any one transaction are
843
+ bounded at 256 MiB. A write that would pass that bound is refused before anything is recorded,
844
+ and its `write` throws. Unsettled writes count toward the bound until `settleWrite` resolves them.
845
+ - **Ordering.** Unlike the in-memory store, each call takes effect when its asynchronous steps
846
+ complete, so unawaited overlapping calls on one request identity can land out of call order.
847
+ Await each call before the next, as the client does.
848
+ - **Damage.** `FileJournalCorruptError` (a damaged record before the log's end or a damaged
849
+ snapshot) and `FileJournalFormatError` (a newer format) refuse the open and change no file. A torn
850
+ final record, which was never acknowledged, is truncated. Nothing is repaired automatically:
851
+ moving the directory aside starts an empty journal and loses the writes it held, which can then
852
+ be settled only by reading the documents on the host.
853
+
302
854
  ### Export download (Node only)
303
855
 
304
856
  `@superbee/core/hosted-client/node` downloads and verifies a hosted bundle's portable export. It
@@ -359,3 +911,68 @@ private directory; callers own its cleanup and isolation while publishing. No ta
359
911
  inventory SHA-256. Keep that evidence outside the directory and private when it identifies a
360
912
  private source. Handles cannot be reconstructed from JSON or moved between independently bundled
361
913
  module instances; modified handles are refused. This surface requires Node, not a browser or Worker.
914
+
915
+ ## Built-in recipes and Kind authoring
916
+
917
+ `@superbee/core/builtin-recipes` is an opt-in catalog: `builtinNames()` lists names,
918
+ `builtinRecipeFiles(name)` returns detached source files, and `loadBuiltinRecipe(name)` returns
919
+ the existing parser's result or `null` for an unknown name. The generic parser, package root,
920
+ and `initBundle` do not import the catalog or install recipes.
921
+
922
+ `@superbee/core/kind-authoring` provides inference, forecasts, dismissal classification, templates,
923
+ and guarded draft/apply/dismiss operations on a caller-selected `Bundle` backend. An outside app
924
+ can use the existing backend and mutation seams without a CLI subprocess:
925
+
926
+ ```ts
927
+ import { MemoryBackend } from "@superbee/core/memory-backend";
928
+ import { query } from "@superbee/core/engine";
929
+ import { mutateDocument } from "@superbee/core/document-mutation";
930
+ import { buildKindRegistry } from "@superbee/core/kinds";
931
+ import { loadBuiltinRecipe } from "@superbee/core/builtin-recipes";
932
+ import { installRecipe } from "@superbee/core/recipe-lifecycle";
933
+ import { kindInstanceCandidate, prepareKindDraft, applyKindDraft } from "@superbee/core/kind-authoring";
934
+
935
+ const backend = new MemoryBackend(), bundle = { root: "memory://project", backend };
936
+ await backend.writeReserved("", "index.md", "---\nokf_version: '0.2'\n---\n# Project\n");
937
+ const loaded = loadBuiltinRecipe("work-tracking");
938
+ if (!loaded?.ok) throw new Error("Recipe unavailable");
939
+ await installRecipe(bundle, loaded.recipe);
940
+ const registry = buildKindRegistry(await query(backend, { prefix: "conventions/" }));
941
+ const task = registry.kinds.get("Task")!;
942
+ const candidate = kindInstanceCandidate(task, { title: "First task", superbee_progress_status: "todo" });
943
+ await mutateDocument({ bundle, id: "tasks/first", mode: "create-only", registry, strict: true,
944
+ buildCandidate: () => candidate });
945
+
946
+ // Once an ungoverned type has existing instances, inspect its draft before applying.
947
+ const text = { inferredBody: () => "The accepted shape of existing research records.\n" };
948
+ const plan = await prepareKindDraft(bundle, "Research", text); // requires existing Research docs
949
+ // Present candidateDoc, warnings and promotions; after acceptance:
950
+ await applyKindDraft(bundle, "Research", plan.token, text);
951
+ ```
952
+
953
+ `kindInstanceCandidate` accepts already-typed field values. Existing validators own their meaning;
954
+ there is no CLI flag coercion or path-alias resolution. Supply a canonical id and use strict
955
+ create-only mutation. The template keeps `type` equal to `governs`, preserves own prototype-shaped
956
+ fields, scaffolds sections only when body is omitted, and retains an explicit empty body.
957
+
958
+ Public draft/apply/dismiss operations trim type names and refuse blank or non-string names before
959
+ any backend access, matching convention parsing. `prepareKindDraft` selects a catalog definition
960
+ or infers from instances. Its token binds candidate
961
+ bytes (including supplied `inferredBody`/`inferredDescription`), edition, presence statistics and
962
+ warning forecast. Pass the same prose options to `applyKindDraft`, which recomputes the plan and
963
+ checks edition and declaration-free redraft eligibility against every fresh CAS head. New
964
+ conventions are expect-absent creates; accepted redrafts replace dismissal prose in place and
965
+ report dropped links. `dismissKind` accepts presentation-owned description/body, records no schema
966
+ obligations, refuses a real schema, and is idempotent. Definition writes retain recipe
967
+ installation's generation-clock opt-out. `KindAuthoringError.reason` identifies domain refusals;
968
+ normal mutation/storage errors retain their types.
969
+
970
+ Presentations with an established mutation adapter may pass the options from
971
+ `prepareKindDraftMutation` or `prepareKindDismissal` through that adapter. These are the same
972
+ specifications used by public apply/dismiss; keep preparation adjacent to mutation. Outside apps
973
+ normally use `applyKindDraft`/`dismissKind`, which own the read-and-apply sequence. No cross-document
974
+ transaction or authorization boundary is added.
975
+
976
+ The packed proof runs the public workflow over `MemoryBackend` in Node and a browser bundle without
977
+ Node globals. This proves source/package availability. Hosted provisioning routes, MCP modeling
978
+ tools, and publication of a core version remain separate adoption and release decisions.