@thomasfosterau/pulpit 0.0.0-stage → 0.1.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1272 -2
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +145 -3
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
package/README.md CHANGED
@@ -1,3 +1,1273 @@
1
- # Temporary Holding Version
1
+ # @thomasfosterau/pulpit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The kernel of a general-purpose Effect headless CMS: versioned content of any shape, with its history and review, publication, structure and routing, participation and user-generated content, discovery and search, media, localisation and erasure, each a module of its own behind seams the application fills. The kernel names no application type: a `page`, a `post` or an `album` is a leaf the application declares and binds to the capabilities it needs. Optional packages (the WordPress-compatible read API, [`@thomasfosterau/pulpit-wordpress`](../pulpit-wordpress/README.md), today) build on it; it imports none of them.
4
+
5
+ **Disclaimer:** This repository and package have been created using Claude Code. The package's API and functionality is in flux, so expect churn and breaking changes.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @thomasfosterau/pulpit effect @thomasfosterau/effect-jsonapi
11
+ ```
12
+
13
+ `effect` (`^4.0.0`) and `@thomasfosterau/effect-jsonapi` (`>=0.17.0`) are required peer dependencies; the package is built against `effect@4.0.0` and `@thomasfosterau/effect-jsonapi@0.17.0`. Node.js 22 or newer is required.
14
+
15
+ ## API
16
+
17
+ Every module is a namespace on the root export (`import { Content, Registry } from "@thomasfosterau/pulpit"`) and a subpath of its own, except `./testing`, which is a subpath only, so that nothing imports the fakes by accident.
18
+
19
+ | Subpath | Contents |
20
+ | --- | --- |
21
+ | `.` | Every module below as a namespace (`Content`, `Registry`, `VersionChain`, `Filter`, `Subtype`, `Publication`, `Documents`, `Edges`, `Proposals`, `Blobs`, `Paths`, `Moderation`, `WorkingCopy`, `Comments`, `Purge`, `Search`, `Budgets`, `Reactions`, `Cache`, `Collections`, `Social`). |
22
+ | **Content model** | |
23
+ | `./content` | The Content-axis field spreads (`visibility`, `draft`, timestamps, the tombstone), `ownerRef`, the base `content` resource. |
24
+ | `./registry` | The registry flag vocabulary (`owned` ⊕ `referenceData`, `pageable`, `agent`, `searchable`, `routable`, `localised`) and `validateRegistry`. |
25
+ | `./documents` | The content-addressed `DocumentStore` seam and its first-insert hook, the `DocumentHash` it is keyed by, the `documentRef` field spread, and the hash contract's `canonicalText`. |
26
+ | `./filter` | `ListFilter`, the read-predicate tree a store renders to SQL, and its in-memory `evaluate`. |
27
+ | `./subtype` | `LeafAccess`, the read scope and viewer predicates, the write gates, `defineSubtype`, `subtypeHandlers`, and the lifecycle hook seam (before / after every write). |
28
+ | **History and review** | |
29
+ | `./version-chain` | The append-only node/version chain operations over a `VersionChainStore` seam, and the field-level version compare (`diffStates`, `compare`). |
30
+ | `./working-copy` | An editor's autosave buffer outside the version chain (`WorkingCopyStore`), committed as one ordinary version (`defineWorkingCopy`). |
31
+ | `./proposals` | `defineProposals`, suggested edits over a `ProposalStore` seam: a contributor's patch against a base version, its two-tier stale / conflict classification, and accept (through the target's own update, recording `proposedBy`), reject and withdraw. |
32
+ | `./moderation` | The moderation workflow: the transition rules as a before hook, the decision, the review-queue filter, threshold grading and member reports over a `ReportStore` seam. |
33
+ | **Publication** | |
34
+ | `./publication` | The publication and moderation predicates, the scheduled-publication sweep window, and the cache serveability policy. |
35
+ | `./cache` | The reference resource cache (`makeResourceCache` over a `VersionedKv` seam): the kernel's compare-on-set `ResourceCacheSeam` with a read-through `get`, serveability checked on write and read, decode-or-evict and a TTL backstop. |
36
+ | **Structure and routing** | |
37
+ | `./edges` | `defineEdgeOps`, the edge operations factory over an `EdgeStore` seam: relations between nodes, readable only when every end is, with declared cardinality limits. |
38
+ | `./paths` | Routable content: `routable`, a leaf's path claims tied to its chain over a `PathStore` seam, the aliases a path change leaves behind, and the gated `resolve`. |
39
+ | `./collections` | Ordered collections (`defineCollection` over a `CollectionStore` seam): positioned members of a parent node, with the order kept beside the chain (the default) or versioned with the parent. |
40
+ | **Participation and user-generated content** | |
41
+ | `./budgets` | Write budgets: a declared ceiling on an account's writes of one kind in a rolling window, spent inside the write (`budgetHook` on a chain write; `bindBudget`'s `spend` on a proposal's `submit` or a report's `file`) over a `BudgetCounter` seam, refused as a rate-limited `WriteRejected` (the wire's 429). |
42
+ | `./comments` | The comments family: threaded comments on any node as versioned owned leaves (`defineComments`), behind the target's read gate and the moderation gate, with a configurable reply depth. |
43
+ | `./reactions` | Reactions (`defineReactions` over a `ReactionStore` seam): one per account per node, a kind from the family's own vocabulary, behind the target's read gate, with a grouped tally for a page of nodes. |
44
+ | `./social` | The social graph (`defineSocial` over a `SocialStore` seam): follow, mute and block between accounts, the follower seam filled from it (`socialFollowers`), and the opt-in read filter over the authors a viewer silenced. |
45
+ | **Media** | |
46
+ | `./blobs` | File and media leaves: the `BlobStore` seam keyed by content hash, the file field spread, the commit-ordered byte write (`defineFileWrites`, with its `bytesPending` marker) and the usage index's edge ends. |
47
+ | **Erasure** | |
48
+ | `./purge` | Erasure (`definePurge` over a `PurgeStore` seam): the one destructive path, which hard-deletes a node's chain and the rows kept for it, then the documents and bytes no other version references. |
49
+ | **Discovery and search** | |
50
+ | `./search` | Hybrid search: reciprocal rank fusion (`fuseRankings`), a reranker seam, `hybridSearch` over `SearchBranch` seams that each apply the leaf's read scope, and the embedding pipeline (`enqueueEmbeddings`, `embedPending`, `sweepEmbeddings`) over `EmbeddingStore` and `Embedder` seams. |
51
+ | **Testing** | |
52
+ | `./testing` | In-memory fakes of the seams (the chain, leaf, document, edge, proposal, blob, path, report, publication, working-copy, purge, embedding, reaction, collection and social stores, the budget counter and write counts, a versioned KV, an endpoint view, a follower seam, a search branch and its scope check), for tests only. Not on the root export. |
53
+
54
+ **Discovery and search** is also `./subtype`'s searchable projection, read scope and hit hydration (see [Search](#subtype) under `./subtype`), which `./search` builds on. **Localisation** is the version chain's locale level (`./version-chain`, `./subtype`'s `getLocalised` and `./filter`'s `locale` arm), not a module of its own.
55
+
56
+ The operations are written against seams that the application fills; their expected shapes are in [Seams](#seams).
57
+
58
+ ## `./content`
59
+
60
+ The Content-axis field spreads a Content subtype opens its `Resource.make` attributes with. One declaration drives the resource object, its documents and the create / update projections.
61
+
62
+ ```ts
63
+ import { Resource } from "@thomasfosterau/effect-jsonapi";
64
+ import * as Schema from "effect/Schema";
65
+ import {
66
+ contentVisibilityResourceFields,
67
+ ownedContentLeafResourceFields,
68
+ ownerRef,
69
+ } from "@thomasfosterau/pulpit/content";
70
+
71
+ // The account family the owner linkage points at (the application's own).
72
+ const UserResource = Resource.make("users", { attributes: { name: Schema.String } });
73
+
74
+ export const PostResource = Resource.make("posts", {
75
+ attributes: {
76
+ ...ownedContentLeafResourceFields, // createdAt, updatedAt, deletedAt?, visibility
77
+ ...contentVisibilityResourceFields, // visibility, draft
78
+ title: Schema.NonEmptyString,
79
+ },
80
+ relationships: { ...ownerRef(() => UserResource) },
81
+ });
82
+ ```
83
+
84
+ | Export | What it is |
85
+ | --- | --- |
86
+ | `Visibility` | `"public" \| "followers" \| "private"` — the access axis. Only `public` is ever cache-serveable. |
87
+ | `contentVisibilityResourceFields` | `visibility` + `draft`: required on the resource, optional at create and update, never cleared. |
88
+ | `contentVisibilityCreateFields` | The same pair as `Schema.optional` keys, for a hand-written create struct. |
89
+ | `ownedVisibilityResourceFields` | `visibility` alone, for owned Entity / File leaves (no `draft`). |
90
+ | `nodeTimestampResourceFields` | `createdAt` / `updatedAt`, read-only. |
91
+ | `tombstoneResourceFields` | `deletedAt`, an optional key on the resource and in no write projection. |
92
+ | `referenceLeafResourceFields` | Timestamps + tombstone: every reference-data leaf. |
93
+ | `ownedContentLeafResourceFields` | Timestamps + tombstone + `visibility`: every owned Content leaf. |
94
+ | `ownerRef(() => Owner)` | The `owner` to-one linkage every owned leaf carries. The operation layer stamps it from the viewer. |
95
+ | `ContentResource`, `ContentRef`, `makeContentResource(id)` | The base `content` resource, with the auto-derived id or the application's own brand. |
96
+
97
+ ## `./registry`
98
+
99
+ The flag vocabulary an application's resource registry is declared in, and the construction-time checks that keep it honest. The tree is the application's; the vocabulary and the checks are the kernel's.
100
+
101
+ ```ts
102
+ import { Resource } from "@thomasfosterau/effect-jsonapi";
103
+ import * as Schema from "effect/Schema";
104
+ import {
105
+ parent,
106
+ root,
107
+ validateRegistry,
108
+ registryWireTypes,
109
+ type RegistryEntry,
110
+ } from "@thomasfosterau/pulpit/registry";
111
+
112
+ // In an application, the `PostResource` from the `./content` example above.
113
+ const PostResource = Resource.make("posts", { attributes: { title: Schema.String } });
114
+
115
+ const REGISTRY = [
116
+ { key: "node", parent: root },
117
+ { key: "content", parent: parent("node") },
118
+ {
119
+ key: "posts",
120
+ parent: parent("content"),
121
+ resource: PostResource,
122
+ id: PostResource.Id,
123
+ owned: true,
124
+ pageable: true,
125
+ },
126
+ ] as const satisfies readonly RegistryEntry[];
127
+
128
+ validateRegistry(REGISTRY); // throws RegistryDeclarationError at import on a malformed tree
129
+ registryWireTypes(REGISTRY, ["content"]).owned; // ["posts"]
130
+ ```
131
+
132
+ | Export | What it is |
133
+ | --- | --- |
134
+ | `RegistryEntry`, `RESOURCE_FLAGS` | The entry shape and its flags: `pageable`, `agent`, `owned`, `referenceData`, `searchable`, `routable`, `localised` (a locale level in its chain; see [Localisation](#subtype)). `owned` and `referenceData` are mutually exclusive. |
135
+ | `parent(key)`, `root` | Declare an entry's parent with its key literal preserved (`Option.some` widens it to `string`, which empties every typed derivation). |
136
+ | `validateRegistry(entries, checks?)` | Unique keys, parents resolve, a flag implies a resource, the trust axis, id / wire-type lockstep; then the application's own `RegistryCheck`s. |
137
+ | `branchOf(entry, byKey, roots)` | The branch root an entry sits under, walking marker levels. |
138
+ | `flaggedMembers(entries, flag)`, `branchMembers(entries, root, roots, parents?)` | The resources themselves, typed as the precise member tuple, ready for `Schema.Union`. |
139
+ | `identifiersOf(members)` | The members' `identifier` schemas, for a linkage-ref union. |
140
+ | `registryWireTypes(entries, roots)` | The wire types of every flag family and branch — what a wire-identity test pins and a subtype factory checks its `access` against. |
141
+
142
+ ## `./version-chain`
143
+
144
+ The append-only chain discipline, bound to a `VersionChainStore` the application supplies.
145
+
146
+ ```ts
147
+ import * as Effect from "effect/Effect";
148
+ import * as Option from "effect/Option";
149
+ import { defineVersionChain, type VersionChainStore } from "@thomasfosterau/pulpit/version-chain";
150
+
151
+ const program = <E, R>(store: VersionChainStore<E, R>) =>
152
+ Effect.gen(function* () {
153
+ const chain = defineVersionChain("posts", store);
154
+ const actorId = Option.none<string>();
155
+ const { id } = yield* chain.create({ id: Option.none(), state: { title: "Hello" }, actorId });
156
+ yield* chain.update({ id, patch: { title: "Hi", summary: null }, actorId }); // null clears; an absent key carries forward
157
+ yield* chain.update({ id, patch: { title: "Hey" }, actorId, expectedVersion: 1n }); // StaleVersion unless the head is 1
158
+ yield* chain.delete(id, actorId); // tombstone; idempotent
159
+ yield* chain.restoreVersion(id, 0n, actorId); // appends version N+1 with version 0's state
160
+ const { changes } = yield* chain.compare(id, 0n, 1n); // { title: changed "Hello" → "Hi", summary: added null }
161
+ });
162
+ ```
163
+
164
+ Every write lands at `N_max + 1`; nothing is rewritten. A plain `update` never resurrects a tombstone (`restore` and `restoreVersion` do). A create is never an upsert (`NodeAlreadyExists`). `restoreVersion` refuses a version that cannot be fully reconstructed before writing anything. `history` is bounded (`MAX_HISTORY_LIMIT`).
165
+
166
+ **Optimistic concurrency.** `update`, `restore`, `delete` and `restoreVersion` take an optional `expectedVersion` (a `Precondition`): the head version the caller last read. If the head has moved, the write fails `StaleVersion`, which carries the head it found (`current`), and nothing is written. Without one, a write extends whatever the head is, so a column that two patches both set is last-writer-wins. The check runs against the same head read the append extends, and the append lands at that head + 1. A writer that slips in between therefore takes the `(id, version)` key first, and the later append fails `VersionConflict`. The store's key is what makes the check atomic, so the seam needs nothing new. A delete over a tombstone stays idempotent whatever the precondition (the delete it asked for has happened). A missing node is still `NodeNotFound`, and a plain `update` over a tombstone is still `NodeNotFound`, whatever version it expects.
167
+
168
+ **Comparing versions.** `diffStates(from, to)` is the field-level change set between two states: one entry per column that is `added`, `removed` or `changed`, each with its `before` and `after` values. Unchanged columns are omitted, so equal states diff to `{}`. A `null` is a value, so a column cleared to `null` is `changed`, and only a column with no key is absent (`added` / `removed`). Values compare structurally with Effect's `Equal.equals`: JSON objects and arrays by content (ignoring key order), a `Date` by its instant, and a `bigint` by value. A content-addressed document is held as its hash (`documentRef`), so it compares by hash, and a textual diff of the document is the UI's job. `compare(id, from, to)` makes two `getVersion` reads and returns both snapshots with `changes: diffStates(from.state, to.state)`. It fails `VersionNotFound` for either version, checking `from` first. A tombstone is the version's `action`, never an attribute: a version compared with the tombstone after it has no changes, and `to.action === "delete"` says the node was deleted there. The chain's `compare`, like its `getVersion`, applies no gate; `./subtype`'s `compare` is the gated one (see [`./subtype`](#subtype)).
169
+
170
+ | Export | What it is |
171
+ | --- | --- |
172
+ | `defineVersionChain(type, store, hooks?)` | The operations (`create`, `update`, `restore`, `delete`, `getVersion`, `compare`, `restoreVersion`, `history`) for one leaf type. |
173
+ | `ChainHooks`, `ChainAppendEvent`, `WriteAction` | The chain's one hook point, `beforeAppend`: shown each write's action (`create`, `update`, `delete`, `restore`, `restoreVersion`), the head it extends and its patch, after the head read and before the append, and returning the patch to apply. `./subtype`'s before hooks run through it. |
174
+ | `VersionChainStore<E, R>` | The persistence seam (see [Seams](#seams)). |
175
+ | `ChainState`, `ChainPatch`, `applyPatch` | A leaf's state at one version, the tri-state patch over it (a value sets, `null` clears, `undefined` carries forward), and the merge. |
176
+ | `LocaleStates`, `LocalePatch`, `applyLocalePatch`, `localesOf` | A localised leaf's locale level at one version (its translated columns per locale, beside the shared `state`), the per-locale tri-state patch over it (a patch updates a locale, `null` drops it, absent carries it forward), its merge, and a snapshot's level (`{}` for none). See [Localisation](#subtype). |
177
+ | `diffLocales(from, to)`, `LocaleDiff`, `LocaleChange` | The per-locale change set (`added` / `removed` / `changed`, the last with its column `StateDiff`); `compare` returns it as `locales`, beside the shared `changes`. |
178
+ | `Precondition` | The optional `expectedVersion` every write to an existing node takes. |
179
+ | `diffStates(from, to)`, `StateDiff`, `AttributeChange`, `VersionComparison` | The pure field-level change set between two states, its shape (`added` / `removed` / `changed` per column, with `before` / `after`), and what `compare` returns. |
180
+ | `NodeNotFound`, `VersionNotFound`, `NodeAlreadyExists`, `StaleVersion`, `VersionConflict` | The tagged failures: the wire's 404 / 404 / 409, a failed expected-version precondition (412 for an `If-Match`, or 409; it carries the head), and a concurrent writer's `(id, version)` collision, which is never retried. |
181
+
182
+ ## `./filter`
183
+
184
+ The kernel's read predicates as data: a store renders a `ListFilter` to SQL, and `evaluate` runs it against an in-memory row, so a list filter and its row predicate can be proven to agree.
185
+
186
+ | Export | What it is |
187
+ | --- | --- |
188
+ | `ListFilter<Following>` | The predicate tree. `Following` is whatever the follower seam hands the `in` node: a subquery for SQL, a set in memory. |
189
+ | `always`, `never` | No predicate; a predicate no row satisfies (fail closed). |
190
+ | `eq`, `ne`, `isIn` | `col = v`, `col <> v`, `col IN (<seam subquery>)`: the follower seam's followed accounts, or an edge family's members (`EdgeOps.memberFilter`). |
191
+ | `isNotIn` | `(col IS NULL OR col NOT IN (<seam subquery>))`: the exact complement of `isIn` over the same seam set, a `NULL` column included; an edge family's non-members (`EdgeOps.nonMemberFilter`, [#191](https://github.com/thomasfosterau/pulpit/issues/191)). |
192
+ | `gt`, `gte`, `lt`, `lte`, `Comparable` | `col > v`, `col >= v`, `col < v`, `col <= v`, over a `Comparable` (`string`, `number` or `Date`). |
193
+ | `anyOf`, `noneOf`, `MAX_SET_VALUES` | `col = ANY(values)`, `col <> ALL(values)` over a literal set of `Scalar`s; an empty `anyOf` is `never`, an empty `noneOf` is `always`, and a set of more than `MAX_SET_VALUES` (1000) values, duplicates counted, is `never` (`SEC-INPUT-003`). |
194
+ | `isNull` | `col IS NULL`: the one node a `NULL` column satisfies on its own, so `or(isNull(col), noneOf(col, values))` is an exclusion that keeps an unset column. |
195
+ | `published`, `notPublished` | Stamped and arrived; its exact complement (unstamped, or stamped in the future). |
196
+ | `locale(locales)`, `LOCALE_COLUMN` | The locale arm (pulpit#56): a localised view's rows in one of `locales`. Empty, or more than `MAX_SET_VALUES`, is `never`. |
197
+ | `and`, `or` | Combinators that drop identity parts, short-circuit on the absorbing part, and return a single part bare. |
198
+ | `evaluate(filter, row, context)` | The in-memory reading, against `context.now` and `context.contains`. |
199
+
200
+ A SQL renderer must honour this contract:
201
+
202
+ | Node | SQL |
203
+ | --- | --- |
204
+ | `always` | no predicate (`undefined` to Drizzle's `.where`) |
205
+ | `never` | a predicate no row satisfies |
206
+ | `eq` / `ne` | `col = $v` / `col <> $v`: a `NULL` column satisfies neither, so `evaluate` drops a row whose column is `null` or absent from both |
207
+ | `in` | `col IN (<seam subquery>)`: the subquery a seam hands over (the follower seam's `following`, or `EdgeStore.members`), never a materialised array. A `NULL` column satisfies none, so `evaluate` drops a row whose column is `null` or absent without asking `context.contains` |
208
+ | `notIn` | `(col IS NULL OR col NOT IN (<seam subquery>))`, over the same subquery `in` takes, so `in` and `notIn` partition every row. A `NULL` column satisfies it, and `evaluate` keeps a row whose column is `null` or absent without asking `context.contains`. The subquery must never yield a `NULL`: `NOT IN` over a set holding one is `NULL` for every row |
209
+ | `gt` / `gte` / `lt` / `lte` | `col > $v` / `col >= $v` / `col < $v` / `col <= $v`. A string compares with `COLLATE "C"` (code-point order; `evaluate` does not use JavaScript's UTF-16 `<`), a `Date` as a timestamp, a number numerically, `Infinity` and `-Infinity` each equal to itself. A `NULL` column satisfies none |
210
+ | `anyOf` | `col = ANY($values)`: a bound array, each value tested as `eq` tests it. A `NULL` column satisfies none. The array never holds a `NULL` element |
211
+ | `noneOf` | `col <> ALL($values)`, each value tested as `ne` tests it. A `NULL` column satisfies none (`NULL <> ALL(…)` is `NULL`), so `noneOf` is not the complement of `anyOf` over a nullable column. Over no values it is true for every row, `NULL` included, which the constructor folds to `always`. The array never holds a `NULL` element: `2 <> ALL('{1,NULL}')` is `NULL`, so one would drop every row |
212
+ | `isNull` | `col IS NULL`; `evaluate` reads a column that is `null` or absent as satisfying it |
213
+ | `published` | `(col IS NOT NULL AND col <= now())`: the database's clock, one per query |
214
+ | `notPublished` | `(col IS NULL OR col > now())`, parenthesised so it cannot bind to a surrounding `AND` |
215
+ | `locale` | `col = ANY($locales)` over the view's `locale` column, as `anyOf`; a row with a `NULL` locale satisfies none, and on a view with no locale level it renders as `never` |
216
+ | `and` / `or` | `AND` / `OR` over the parts |
217
+
218
+ An `anyOf` / `noneOf` node with more than `MAX_SET_VALUES` values (duplicates counted) renders as `never`, and `evaluate` reads it so. The constructors already return `never` for one, so only a hand-built node reaches a renderer that way. A cap that dropped some of a `noneOf`'s values would widen the result, so the arm fails closed instead.
219
+
220
+ Some inputs are outside the contract: SQL and `evaluate` can't be made to agree on them, so `evaluate` fails closed.
221
+
222
+ - **A column of another type than the comparand**, or than any of a literal set's values, is a caller or schema error. PostgreSQL may coerce the parameter (an `integer` column against `'1'`) or raise (against `'abc'`). `evaluate` reads such a row as satisfying no `eq`, `ne`, comparison, `anyOf` or `noneOf`.
223
+ - **`NaN`, and an invalid `Date`.** PostgreSQL's `float8` and `numeric` hold `NaN` equal to itself and greater than every number; JavaScript's `NaN` has no such order. Every constructor returns `never` for a `NaN` or invalid-`Date` comparand, and `anyOf` / `noneOf` return `never` for a set holding a `NaN`. `evaluate` reads a `NaN` comparand, set element or column as satisfying no `eq`, `ne`, comparison, `anyOf` or `noneOf`. A renderer must never bind `NaN`: it renders a hand-built node carrying one as `never`.
224
+ - **Sub-millisecond instants.** A PostgreSQL timestamp keeps microseconds and a JavaScript `Date` milliseconds, so two instants within one millisecond of each other can compare unequal in SQL and equal in `evaluate`.
225
+
226
+ ## `./subtype`
227
+
228
+ `defineSubtype` binds a leaf's CRUD, version history and list over the chain store, a current-version `LeafStore`, and the viewer seam (`viewer`, `AccessPolicy`, `FollowerSeam`), gated by `LeafAccess`:
229
+
230
+ - `reference` — writes at the corpus-write rung; when the view has a `visibility` column, reads only `public` rows below the view-private rung, apart from soft-delete. A leaf whose rows may be `followers`-visible declares the view column that plays the owner's part, `{ kind: "reference", followersOfColumn: "curatorId" }` (see [Seams](#the-viewer-the-role-axes-and-the-follower-seam)): a `followers` row is then also read by the account that column names and by its followers, and by nobody else below the view-private rung (that account's `private` rows stay the rung's alone). Without the declaration a `followers` row is the rung's alone. A write or history read to an existing row also needs the caller to be able to `get` it: a row `get` hides from them (or a missing one) fails `NodeNotFound` before the chain is reached, so only the view-deleted rung can `restore` a tombstone.
231
+ - `owned` — creates at any account (stamping the owner); other writes at owner-or-moderator; reads through `canViewOwned` (three-state visibility plus `draft` and, on a moderated leaf, the moderation state), lists through `ownedListFilter`, the same facts as a `ListFilter`. A leaf declaring `moderationColumn` (`ownedAccess({ moderation: true })` names `moderationState`) shows a row whose state isn't exactly `approved` only to its owner and the view-private rung, as it does a draft (pulpit#154). A row whose owner column holds no string (a `NULL`) has no owner: no viewer owns it and the follower seam is never asked about it, so it reads only when `public` and not a draft, or at the view-private rung.
232
+
233
+ `get`, `getMany` and `list` treat a row the viewer may not see as missing (`NodeNotFound`, never `Forbidden`). So do a reference leaf's write and history operations. An owned leaf's don't, deliberately (pulpit#24): their gate answers `Forbidden` for an existing owned row the caller doesn't own, whatever its visibility, draft or delete state, so it confirms existence to a signed-in caller; see [SECURITY.md → Existence disclosure by the write gates](https://github.com/thomasfosterau/pulpit/blob/trunk/SECURITY.md#existence-disclosure-by-the-write-gates). `subtypeHandlers(ops, { transact, cache })` wraps the operations as the handler record a JSON:API server is expected to accept (see [Seams](#seams)), writing every mutation through the resource cache.
234
+
235
+ The version history is gated like a write (`READ-009`): `getVersion(id, version)` and `compare(id, from, to)` run the same gate as `update`, through one code path, before any version is read, and then delegate to the chain's `getVersion` / `compare`. So `compare` answers exactly as `getVersion` would for `from` and then `to`: `Unauthorized`, `Forbidden` or `NodeNotFound` from the gate, whichever version is asked for, and `VersionNotFound` (naming `from` first) only to a caller the gate admits. `subtypeHandlers` doesn't expose `compare`: no JSON:API operation exists for it, so an application calls it directly.
236
+
237
+ `list` takes an optional `orderBy`, a list of `{ column, direction }` keys (`direction` is `"asc"` or `"desc"`); absent, empty or `null` is `id` ascending. The rows sort by each key in turn, a `NULL` (or absent) column after every value in both directions, then by `id` ascending, so the order is total and an offset page never skips or repeats a row. A string sorts by code point, a number or `bigint` by value, `false` before `true` and a `Date` by its instant. Each key is checked before the viewer or the store is read: a column that is not sortable, a direction other than `asc` / `desc`, or input not shaped as an order (an `orderBy` that isn't an array, a key that isn't an object, a `column` that isn't a string) fails `InvalidListOrder` and never reaches the store. Its `column` is the caller's own key, or `""` when the failing key (or a non-array `orderBy`) has no string column to name, which an adapter can map to JSON:API's `source.parameter` (the whole `sort`) rather than one field. A repeated column is dropped, since its first key already ordered every tie it could. The order decides only where the visible rows fall: the privacy, soft-delete and `where` filters choose them exactly as before, and `total` is the same under every order (`SEC-AUTH-ENUM-001`).
238
+
239
+ **Which columns sort: `sortable`, and its default.** `defineSubtype` takes an optional per-leaf `sortable` allow-list of column names. Each entry must be one of the view's `LeafStore.columns`, or `defineSubtype` throws `SubtypeDeclarationError` when the leaf is declared. With a list, only its columns sort; any other column, a declared one included, fails `InvalidListOrder`. With a list, `id` sorts only if listed; `[]` admits only the default `id`-ascending order, so any non-empty `orderBy` fails `InvalidListOrder`. **When `sortable` is omitted, every declared view column is sortable**, including columns the resource never shows. That default discloses two things to any caller:
240
+
241
+ - **The visible rows' ranking by any view column.** A caller can sort the rows it may already see by a column the API doesn't expose (a `rank`, a moderation score) and so learn that column's order among them.
242
+ - **Which names are view columns.** The `InvalidListOrder` 400 answers a name that isn't one, and a list answers one that is.
243
+
244
+ It reveals no hidden row and changes no total. Declare `sortable` to sort only by what the resource exposes; an adapter mapping a wire parameter (WordPress's `orderby`) should still map onto its own names. Every `sortable` column must hold a scalar orderable type (see the [order contract](#leafstorerow-following-e-r-the-current-version-view)).
245
+
246
+ `update`, `restore`, `delete` and `restoreVersion` (and the handlers' `update`, `restore`, `delete` and `restoreVersion`) take the chain's optional `expectedVersion` and fail `StaleVersion` when it is not the head. The write gate runs first, so a refused caller learns nothing from the failure.
247
+
248
+ **The head version on the wire** (pulpit#65). Every `LeafRow` carries its head `version` (see [Seams](#leafstorerow-following-e-r-the-current-version-view)), so a read can tell the client the version its next write should expect. The mapper stamps it as the resource object's `meta.version`, a decimal string (`bigint` has no JSON form), with `versionMeta(row)`: `toResource: (row) => ({ type: "posts", id: row.id, attributes: …, meta: versionMeta(row) })`. A single-resource response (`get`, `create`, `update`, `restore`, `restoreVersion`) also sends `versionEtag(resource)` as its `ETag` header: the strong tag `"<version>"`, read back from that `meta.version`, so the two never disagree. A collection response sends no `ETag`, since it has no single version; each resource in it still carries its own `meta.version`. The version reaches only a resource the read already serves the viewer, so it says nothing about a hidden row (`SEC-AUTH-ENUM-001`). What it does disclose about a row the viewer can see is how many versions its chain holds.
249
+
250
+ **Search** (pulpit#54). The ranking queries (full text, trigram, vectors) are the application's SQL; pulpit owns three things around them here, and their fusion and the embedding pipeline in [`./search`](#search) (pulpit#204).
251
+
252
+ - **The projection.** `defineSearchProjection({ fields, languageColumn? })` declares, once and beside the leaf's registry entry, which view columns the leaf's search view indexes, each with a PostgreSQL `setweight` label (`A`–`D`), and the column naming each row's text search configuration. It throws `SubtypeDeclarationError` at import for no field, a column named twice or a weight outside `A`–`D`. Passed to `defineSubtype` as `search`, each column it names must be a view column, or `defineSubtype` throws too. The application's search-view generator reads the same value.
253
+ - **The scope.** `searchScopeFilter({ access, columns, policy, followers }, viewer, { includeDeleted, where })` is the `ListFilter` a search must apply to its hits, rendered into the search query as a list store renders it. It is the very filter `list` reads under (`list` calls it), so a search surfaces no row `list` would hide (`SEC-AUTH-ENUM-001`): a hidden, draft or tombstoned row, or one the family's `where` excludes. On a leaf with the live pointer (pulpit#46, below), the search must also match over the rows its `LiveView.list` reads, not the head: the viewer's own rows (`headFor`) at the head and every other node's live row. Hydration re-reads the live version anyway, but a match scored against an unpublished head would still tell a reader that the draft contains the term.
254
+ - **Hydration.** `hydrateSearchHits(ops, hits)` re-reads the ranked hits through the leaf's gated `getMany` in one read, so a stale or poisoned index can't leak a row: a hidden or missing hit is dropped, the rest keep their rank order (a repeated id keeps its first), and each comes back as `{ hit, resource }`.
255
+
256
+ **The live pointer** (pulpit#46, opt-in per leaf). A leaf that declares `publication: { live, facts, preview? }` is publish-gated: what readers see is the version its publication log makes **live**, not the head editors work on. Without the declaration, head and live are one, as before.
257
+
258
+ - **Facts, not rewrites.** `publication` (on the operations, an `Option`) is `publish(id, { version?, at? })`, `unpublish(id)`, `discardDraft(id, precondition?)` and `facts(id)`.
259
+ - `publish` and `unpublish` append a `PublicationFact` to the `PublicationStore` (`version`, or none for an unpublish, `at`, the actor, `recordedAt`). The live version is the version named by the most recently recorded fact whose `at` has arrived. So `publish(id, { at })` with a future `at` is a schedule: readers see the version at its stamp, with no write, and a later record supersedes it. `VERSION-CHAIN-001/002` are untouched.
260
+ - `discardDraft` appends the live version's state as a new version (a `restoreVersion`; it writes nothing when the head already is live, and fails `NotPublished` when nothing is).
261
+ - `facts` is the publication history, gated like `getVersion`.
262
+ - Each runs the write gate, then the leaf's before hooks with action `publish` / `unpublish` (the returned patch is ignored; a refusal records nothing). The lifecycle event fires when `publish` is called, not when `at` arrives (pulpit#45); `duePublications` drives an application's sweep.
263
+ - **Who reads what.** The view-private rung, a reference leaf's corpus-write rung and a row's own owner read the **head**; everyone else reads the **live** version, and a node with none is hidden from them.
264
+ - `get` / `getMany` choose per row. `list` goes through the `LiveView`, whose `headFor` filter keeps the viewer's own rows at the head, so `get` and `list` never disagree.
265
+ - On a gated leaf, **`draft` means "no live version"**: both views compute it from the log, and the stored flag is ignored.
266
+ - **Preview.** `preview` is the request's preview grant: the node id a signed, expiring token admits (signing and verifying it are the application's). `get` of exactly that node serves its head to whoever asks, unless it is tombstoned. `getMany`, `list` and every write ignore it.
267
+ - **The cache only ever holds the live version** (`CACHE-AUTH-005`). On a gated leaf `subtypeHandlers` writes through `publicResource(id)`, the resource as an anonymous reader gets it, never the writer's re-read of the head. It evicts when there is none. `publication` on the handlers runs the operations in `transact`, then the after hooks and the cache refresh. A gated leaf's cache posture is `serveablePolicy({ publishGated: true })`: the live view supplies the stamp (`publishedAt`) the cache checks. A `publish` or `unpublish` appends no version, so its write-through and its event's `write.version` carry the node's head: the cache's compare-on-set (pulpit#121) then orders it after the edits before it, where the published version (often below the head) would be refused. Two publication operations on the same head compare equal, so their order isn't protected the way two chain writes' is: the application serialises publication per node (see [Seams](#publicationstoree-r-and-liveviewrow-following-e-r-the-live-pointer), pulpit#164).
268
+ - **Autosave** lives outside the chain, in [`./working-copy`](#working-copy).
269
+
270
+ **Localisation** (pulpit#56, model (b): the locale is a level in the version chain). One node per content item; its locales share its history, its draft and visibility, its publication state and its path claim. A leaf flagged `localised` in the registry declares, beside its entry, which columns are translated: `defineLocaleLevel({ columns: ["title", "body"] })`, passed to `defineSubtype` as `locales`. Every other column is **shared**: it is held once, in the chain's `state`, so every locale of the node carries the same value. The translated columns live in the version's locale level (`locales: { en: { title: "Hello" }, fr: { title: "Bonjour" } }`).
271
+
272
+ - **Declaration.** `defineLocaleLevel` throws `SubtypeDeclarationError` at import for no column, a repeat, or a name the view keeps for itself (`id`, `version`, `action`, `locale`). `defineSubtype` throws when a translated column, or `locale`, is not a view column, and when a translated column is one the read gate reads (an owned leaf's owner, visibility, draft and moderation columns; a reference leaf's `visibility` and `followersOfColumn`; a publish-gated leaf's `draft` and `publishedAt`). Access is the node's, never a locale's.
273
+ - **Writes.** `create` takes `locales`, the whole level at version 0; `update` and `restore` take `locales`, a `LocalePatch` (a patch updates that locale, `null` drops it, an absent key carries it forward). Every write appends one version of the whole node, however few locales it touches. `restoreVersion` restores every locale together with the shared columns, and a tombstone carries them forward. A write fails `WriteRejected`, after the gate and with nothing appended, when its shared patch names a translated column, a locale's patch names a shared column, a locale tag is empty, or the leaf has no locale level.
274
+ - **Before hooks see every locale, and fail closed.** A `BeforeWriteHook` declares a `locales` pass beside `run`: it is shown the write's locale patch and returns the one the write applies, so a hook that validates or rewrites a column does so in every locale. A write that carries a locale change through a hook with no `locales` pass is refused with `WriteRejected`, so writing a column per locale never bypasses a hook. A hook with nothing to do per locale passes the patch through: `locales: (event) => Effect.succeed(event.locales ?? {})`.
275
+ - **Reads.** The leaf's view has one row per `(id, locale)` the version carries, with the shared columns joined in and the tag in `locale` (a node carrying no locale is one row with a `NULL` locale). `list` reads those rows, so narrow it with the `locale([...])` filter arm. `get` / `getMany` read the node's shared row. `getLocalised(id, locales)` is the fallback read: after the node's own read gate, exactly as `get` runs it, one read of the requested locales' rows from the version the viewer reads (the head, or the live version on a publish-gated leaf), and the first locale in the caller's order the node carries, as `{ locale, resource }`. Each locale row passes the read gate again, so a store that joined one onto other access facts serves none. A node that is missing, hidden or carries none of the locales fails `NodeNotFound` (`SEC-AUTH-ENUM-001`).
276
+ - **History.** `compare` reports the shared columns' `changes` and the per-locale `locales` (`diffLocales`).
277
+
278
+ **Lifecycle hooks** (pulpit#45). A leaf's write side effects compose as `LifecycleHooks` on its definition, `hooks: Effect<LifecycleHooks<Resource, E, R>, never, R>`: an Effect, so the application holds them in a `Context` service, composes them per leaf type and provides them with a `Layer`. Omitted, a leaf has none, and a write reads nothing extra. Hooks run in array order (`composeHooks` concatenates sets), so the order is deterministic and the application's.
279
+
280
+ - **Before hooks** run inside every write (`create`, `update`, `restore`, `delete`, `restoreVersion`), after its gate and the chain's head read and before the append, in the caller's transaction. Each is shown the leaf type, the action, the node, the head the write extends (`prior`, none for a create), the patch, and the viewer, and returns the patch the write applies (the event's, or a transformed one: a derived slug, a normalised field); the next hook sees that patch. One that fails, with `WriteRejected` or the store's `E`, fails the write with nothing appended. The expected-version check still runs after it, against the same head.
281
+ - **After hooks** run in `subtypeHandlers`, once the write's `transact` has returned (the operations alone can't know when a write commits), and are shown the type, the action, the `{ id, version }` the write landed at, and the resource as the writer re-read it (none for a delete, or when the re-read failed). They never fail the write: a failure, a defect included, goes to `afterFailure`, `"log"` (the default: logged at error level, and the next hook still runs) or `"defect"` (the handler dies, though the write committed). They run uninterruptibly. The cache write-through is the first after hook (`cacheWriteThrough`), ahead of the leaf's own. Inside an outer transaction (the config's `inTransaction` reads true once `transact` returns, so `transact` was a SAVEPOINT and nothing has committed), the cache step evicts at the write's version instead of writing through, so a write the outer transaction later rolls back never reaches the cache (pulpit#124). The leaf's own after hooks still run there, before the outer commit; until `transact` gains an after-commit hook (pulpit#124's follow-up), a hook that must see only committed writes checks the same signal itself.
282
+ - **Durable delivery is the application's.** An after hook runs once, in process, after the commit; a side effect that must not be lost (a queue message, a webhook) belongs in a before hook, as an outbox row the same transaction writes.
283
+ - **Events fire on explicit writes only.** A delete of an already tombstoned node writes nothing and emits nothing. A version stamped to publish in the future emits nothing when its stamp arrives: drive a sweep from a cron with `./publication`'s `duePublications(since, now)` instead.
284
+
285
+ | Export | What it is |
286
+ | --- | --- |
287
+ | `defineSubtype(definition)`, `SubtypeDefinition`, `SubtypeOps` | The factory, its declaration (type, access, stores, viewer seams, row → resource mapper) and the operations it produces. |
288
+ | `subtypeHandlers(ops, config)`, `SubtypeHandlers`, `SubtypeHandlersConfig`, `ResourceCacheSeam` | The handler record with cache write-through, its `transact` boundary, the `inTransaction` signal that turns the write-through into an eviction inside an outer transaction (pulpit#124), and the cache's write side. |
289
+ | `subtypeResourceHandlers(handlers, config)`, `SubtypeResourceHandlersConfig` | The record as effect-jsonapi's `ResourceHandlers.make` CRUD handlers, with the decoded-input mappings (see [Seams](#subtypehandlers-the-handler-record)). |
290
+ | `LifecycleHooks`, `BeforeWriteHook`, `BeforeWriteEvent`, `AfterWriteHook`, `AfterWriteEvent`, `WriteRejected` | The lifecycle hook seam: a leaf's before hooks (inside the write; may transform the patch or refuse with `WriteRejected`) and after hooks (after the commit; never fail the write), and their events. |
291
+ | `composeHooks(...sets)`, `noHooks()`, `cacheWriteThrough(cache)` | Concatenate hook sets in order; the empty set; the resource cache's write-through as an after hook. |
292
+ | `LeafAccess`, `ownedAccess(options?)` | The gate selector (`reference`, with an optional `followersOfColumn`, / `owned` with its view columns, the draft and moderation columns among them), and the owned declaration with the conventional column names (`{ draft?, moderation? }`). |
293
+ | `Viewer`, `ViewerAccount`, `AccessPolicy` | The viewer seam and the four role axes (`canViewPrivate`, `canViewDeleted`, `canModerate`, `canWriteCorpus`). |
294
+ | `FollowerSeam`, `nobodyFollows(nobody)` | The follower seam and its fail-closed default. |
295
+ | `ReadScope`, `readScopeFor(viewer, policy, followers)` | A viewer's owned-material read scope (`all` / `public` / `member`). |
296
+ | `canViewOwned`, `OwnedFacts` | The owned-row read predicate and the facts it reads. |
297
+ | `ownedListFilter`, `softDeleteFilter`, `referencePrivacyFilter` | The list arms: owned visibility, the soft-delete axis, and the reference privacy axis (given `Option.some({ access, followers })`, with its followers arm). Pass that fourth argument whenever the leaf declares `followersOfColumn`: the three-argument form agrees with `get` only for a leaf without it. |
298
+ | `requireCreator`, `requireWriter` | The write gates for a create and for a write to an existing row. On a reference leaf `requireWriter` is the rung check alone; `defineSubtype` follows it with `get`'s read check. |
299
+ | `Unauthorized`, `Forbidden`, `InvalidListOrder` | The gates' failures: no signed-in viewer (401); a viewer below the rung (403, never used to hide a row). And `list`'s: an order key on a column that isn't sortable, or not shaped as an order (400; `column: ""` when the failing key has no string column). |
300
+ | `SubtypeDeclarationError` | Thrown by `defineSubtype` when the leaf is declared: a `sortable`, `search` or `locales` entry that isn't a column of the view, or a translated column the read gate reads; and by `defineSearchProjection` and `defineLocaleLevel`, for a malformed declaration. |
301
+ | `defineLocaleLevel(declaration)`, `LocaleLevel`, `LocalisedResource`, `SubtypeOps.getLocalised`, `CreateRequest`, `RestoreRequest` | A localised leaf's translated columns, the fallback read's `{ locale, resource }`, the read itself, and the write inputs that carry a locale level (see Localisation, above). |
302
+ | `defineSearchProjection(declaration)`, `SearchProjection`, `SearchField`, `SearchWeight` | A leaf's searchable projection: its indexed view columns, their weights (`A`–`D`) and its language column. |
303
+ | `searchScopeFilter(leaf, viewer, request)`, `hydrateSearchHits(ops, hits)` | The read scope a search applies to its hits, the same filter `list` reads under; and the hits' gated re-read, in rank order. |
304
+ | `LeafStore`, `LeafRow`, `Page`, `ListRequest`, `ListOrder`, `MAX_PAGE_LIMIT` | The current-version view seam, its row (with the head `version`) and page shapes, the list request and one key of its order, and the page cap (1000). |
305
+ | `versionMeta(row)`, `VersionMeta`, `versionEtag(resource)`, `VersionedResource` | The resource object's `meta` for the row's head version (`{ version: "3" }`), and a single-resource response's `ETag` value read back from it (`Option.some('"3"')`, none without a decimal `meta.version`). |
306
+ | `PublicationDeclaration`, `LiveView`, `PublicationStore`, `PublicationFact` | The live pointer's opt-in: the live-version view and the append-only publication log (see [Seams](#seams)), and one fact. |
307
+ | `PublicationOps`, `PublishOptions`, `PublicationWrite`, `NotPublished` | The publication operations (`publish`, `unpublish`, `discardDraft`, `facts`), what `publish` takes and what both report, and `discardDraft`'s failure with nothing live (409). |
308
+ | `SubtypeOps.owners(request)`, `OwnersRequest`, `readableOwners(leaves, request)`, `OwnerSetTooLarge` | The accounts that own at least one row the viewer may list ([#186](https://github.com/thomasfosterau/pulpit/issues/186)). `owners` is one leaf's distinct owner ids, read under exactly `list`'s scope (and its head-or-live choice on a publish-gated leaf), in code-point order with the distinct total, optionally narrowed to `among` (empty on a reference leaf). `readableOwners` is the exact union across leaves, one read per leaf; a leaf with more than `MAX_PAGE_LIMIT` readable owners fails `OwnerSetTooLarge` rather than answer a short union. A hidden row never contributes its owner (`SEC-AUTH-ENUM-001`). |
309
+ | `SubtypeOps.publicResource`, `SubtypeOps.authorize` | The resource as an anonymous reader gets it (what a public cache may hold), and the write gate on its own, for a surface outside the chain. |
310
+
311
+ ## `./publication`
312
+
313
+ The publication axis (stamped AND arrived), the moderation state, and the KV cache's serveability policy.
314
+
315
+ | Export | What it is |
316
+ | --- | --- |
317
+ | `isPublishedAt(stamp, now)` / `isNotPublishedAt` | Stamped AND arrived, and its exact complement (a future stamp is scheduled, not out). |
318
+ | `publishedFilter(column, wanted)` | The axis as a list-filter arm; none in, no predicate out. |
319
+ | `duePublications(since, now, column?)` | The rows whose stamp arrived in `(since, now]`, as a list filter, for an application's cron to sweep scheduled publications: a write's events fire when it is made, never when a future stamp arrives. Abutting windows (each run's `now` the next run's `since`) see each row once. |
320
+ | `ModerationState` | `pending \| approved \| rejected`. |
321
+ | `isPubliclyServeable(resource, { owned?, publishGated?, now? })` | The KV cache's security gate (`CACHE-AUTH-005`): only what every viewer may read is serveable. |
322
+ | `serveablePolicy(options)` | The same gate as a resource cache's `serveable` hook (see [Seams](#seams)). |
323
+
324
+ ## `./cache`
325
+
326
+ The reference resource cache (pulpit#205; owner decision: in the kernel, behind a minimal KV seam). `makeResourceCache` is `./subtype`'s `ResourceCacheSeam`, the one `subtypeHandlers` writes through, plus the read-through `get` an application serves from, over a `VersionedKv` it supplies. It replaces each application's own `cache.ts`.
327
+
328
+ ```ts
329
+ import { makeResourceCache } from "@thomasfosterau/pulpit/cache";
330
+ import { serveablePolicy } from "@thomasfosterau/pulpit/publication";
331
+
332
+ const postCache = makeResourceCache({
333
+ kv, // the application's VersionedKv: a Durable Object, Redis, a SQL table
334
+ schema: PostResource, // the resource's JSON codec
335
+ serveable: serveablePolicy({ owned: true }),
336
+ prefix: "posts:v3:", // bump the generation when the schema changes
337
+ ttlSeconds: 3600,
338
+ });
339
+ const handlers = subtypeHandlers(postOps, {
340
+ transact,
341
+ inTransaction,
342
+ cache: Option.some(postCache),
343
+ });
344
+ postCache.get(id); // Option<Post>
345
+ ```
346
+
347
+ - **Compare-on-set** (pulpit#121). Every entry carries the version it speaks for, and the KV's one conditional write (`putIfNotBehind`) keeps the highest. A `set` or `delete` below the stored version changes nothing, and an eviction raises the version, so a racing writer's late write-through never re-caches an older resource. The KV must compare and write in one atomic step; Workers KV alone can't.
348
+ - **An eviction wins a tie.** A tombstone outranks a value at the same version, so only a strictly newer write clears it: a read-through that loaded the row before an unpublish at that version (publication appends no version) can't re-cache it. The cache stores a rank as the entry's `version`, `2·version` for a value and `2·version + 1` for a tombstone, so the KV still compares one number. The cost: after an eviction, a fill at the same version is refused, and the item reads from the store until a newer version or the tombstone expires.
349
+ - **Tombstones outlive values.** A tombstone is kept for `tombstoneTtlSeconds` (default `DEFAULT_TOMBSTONE_TTL_SECONDS`, a day; never less than `ttlSeconds`). Set it above the longest a write-through or read-through fill can be in flight: a stale write that lands after its tombstone expired is no longer refused.
350
+ - **Serveability on both sides** (`CACHE-AUTH-005`). `set` refuses, and evicts, a resource the policy doesn't serve. `get` checks again and evicts, so a policy change never serves a stale cached private row.
351
+ - **Decode or evict.** `get` decodes with the resource's schema, and a stored value that no longer decodes is evicted and read as a miss. Bump `prefix` with the schema to start a fresh generation.
352
+ - **A TTL backstop** on every value write (`ttlSeconds`), so an entry no write evicted still expires.
353
+ - **Never fails.** A KV failure reads as a miss and skips a write: the store stays the source of truth.
354
+
355
+ | Export | What it is |
356
+ | --- | --- |
357
+ | `makeResourceCache(options)`, `ResourceCacheOptions`, `ResourceCache` | The cache, its options, and the seam plus `get`. |
358
+ | `VersionedKv`, `KvEntry` | The KV seam (see [Seams](#seams)) and one stored entry (its rank and encoded value, none for an eviction). |
359
+ | `DEFAULT_TOMBSTONE_TTL_SECONDS` | How long an eviction is kept by default: 86 400 seconds. |
360
+
361
+ ## `./documents`
362
+
363
+ The content-addressed document store. A version row carries a document's hash rather than another copy of the document, so a long document edited many times is stored once per distinct content, and history (which is never pruned) stays small. A stored document is never rewritten and a changed document is a new hash (`DOC-001`), so `restoreVersion` restores the reference, and the old hash still reads back the old document.
364
+
365
+ ```ts
366
+ import { Resource } from "@thomasfosterau/effect-jsonapi";
367
+ import * as Effect from "effect/Effect";
368
+ import * as Option from "effect/Option";
369
+ import { documentRef, type DocumentStore } from "@thomasfosterau/pulpit/documents";
370
+ import { defineVersionChain, type VersionChainStore } from "@thomasfosterau/pulpit/version-chain";
371
+
372
+ export const NoteResource = Resource.make("notes", {
373
+ attributes: { ...documentRef("title"), ...documentRef("body") }, // each a DocumentHash
374
+ });
375
+
376
+ const program = <Document, E, R>(
377
+ documents: DocumentStore<Document, E, R>,
378
+ store: VersionChainStore<E, R>,
379
+ title: Document,
380
+ body: Document,
381
+ ) =>
382
+ Effect.gen(function* () {
383
+ const chain = defineVersionChain("notes", store);
384
+ const state = {
385
+ title: (yield* documents.put(title)).hash,
386
+ body: (yield* documents.put(body)).hash,
387
+ };
388
+ const { id } = yield* chain.create({ id: Option.none(), state, actorId: Option.none() });
389
+ return { id, body: yield* documents.get(state.body) }; // put is idempotent: the same document, the same hash
390
+ });
391
+ ```
392
+
393
+ The document type is a parameter: its schema and validation are the application's, and pulpit never imports them. The hash is the store's to derive; the algorithm and the bytes it is taken over are in [Seams](#seams), and `canonicalText` produces those bytes for a store that doesn't run on Postgres.
394
+
395
+ | Export | What it is |
396
+ | --- | --- |
397
+ | `DocumentStore<Document, E, R>`, `DocumentPut` | The store seam: `put` (idempotent, returns `{ hash, inserted }`, `inserted` only when it wrote the row), `get`, `getMany` (see [Seams](#seams)). |
398
+ | `onDocumentInsert(store, hook)`, `DocumentInserted` | The store with a first-insert hook (both apps enqueue an embedding job): it runs after a `put` that inserted, never after one that found the document stored, inside the `put`'s transaction, and its failure fails the `put`. |
399
+ | `DocumentHash` | A SHA-256 document hash as 64 lowercase hex characters. |
400
+ | `documentRef(key)` | The attribute spread for a version row's document reference: the hash, required on the resource and at create, carried forward at update, never cleared. |
401
+ | `canonicalText(json)` | The canonical text the hash contract takes SHA-256 over (Postgres's `jsonb` text output), for a store that doesn't run on Postgres. |
402
+
403
+ ## `./edges`
404
+
405
+ `defineEdgeOps` binds the operations of an edge family: a relation between nodes, stored as a row of its own (an id, a `createdAt`, one column per end holding that end's node id, and the family's own qualifier columns) outside both nodes' tables. It is ported from gardeneria's `defineEdgeOpsE`:
406
+
407
+ - `list`: one page under the family's own `where`, at most `MAX_PAGE_LIMIT` (1000) rows, ordered by `createdAt` then `id`, with the unpaged total.
408
+ - `get`: one edge by id.
409
+ - `create`: asserts the edge. On a collision with a stored edge on the family's unique key, `onConflict: "ignore"` returns the stored edge's id with `created: false` when the caller can read that edge and its every end is live (a re-assertion is idempotent, and never rewrites the stored qualifiers); `"fail"`, the default, fails `EdgeConflict`, and so does `"ignore"` when the stored edge is hidden from the caller or has a tombstoned end (the view-deleted rung reads such an edge but can't link to a tombstone, so it can't resolve to one either).
410
+ - `delete`: a hard `DELETE`. There is no general update: correcting an edge is deleting it and asserting the right one.
411
+ - `closeValidity(id, at)`: the one update an edge takes, on a family that declares `validity` ([#210](https://github.com/thomasfosterau/pulpit/issues/210), `EDGE-002`). It sets the interval's `to` column to `at` and nothing else, so an office holding or a membership ends without losing its row. It only narrows: `at` may not fall after a closed end nor before the start (`InvalidValidity`), and a family without `validity` is `InvalidValidity` too. A missing or hidden edge is `EdgeNotFound` (checked before the write gate), then the gate's failures.
412
+ - `memberFilter(end, otherIds)`: edge membership as a list filter for the leaf at `end` ([#113](https://github.com/thomasfosterau/pulpit/issues/113)). It is `id IN (<the end's node ids of the edges whose other end is one of otherIds>)`, so `memberFilter("post", termIds)` passed to the posts' `list` as `where` answers `?categories=1,2`. It counts only edges the viewer can read (`EDGE-001`, the same `endpointFilter` as `list`), so a hidden term, a hidden post or a tombstoned end below the view-deleted rung never makes a row a member. No ids, more than `MAX_SET_VALUES` (`SEC-INPUT-003`), an `end` the family doesn't declare, or a family without exactly two ends all give `never` without reading the store.
413
+ - `nonMemberFilter(end, otherIds)`: the complement of `memberFilter` ([#191](https://github.com/thomasfosterau/pulpit/issues/191)), `(id IS NULL OR id NOT IN (…))` over the same `members` subquery under the `notIn` node, so it answers `?categories_exclude=1,2`; `memberFilter` and `nonMemberFilter` partition the rows a leaf's `list` reads. Only edges the viewer can read count as a membership (`EDGE-001`): a row whose only edge to `otherIds` is hidden from the viewer is not excluded, so the hidden edge is never confirmed. An exclusion fails closed by excluding nothing: no ids, more than `MAX_SET_VALUES` (an adapter caps its list first), an `end` the family doesn't declare, or a family without exactly two ends all give `always` without reading the store.
414
+ - `countBy(end, ids, where?)`: the number of edges the viewer can read at each node of `ids` at end `end` ([#185](https://github.com/thomasfosterau/pulpit/issues/185)), in one grouped store read: a taxonomy term's post count for every term on a page. It counts exactly the edges `list` would show (`EDGE-001`, the same `endpointFilter`, narrowed by the family's optional `where`), so a hidden or tombstoned node at `end` counts `0` and its edges are never confirmed. Every requested id (deduplicated) has a key, `0` when it has no readable edge. More than `MAX_SET_VALUES` ids, or an `end` the family doesn't declare, answers `0` for every id without reading the store.
415
+
416
+ Writes gate on the declaration's `writeGate`. The default, `corpusWriteGate`, is the corpus-write rung, right for an edge between two reference leaves. `rungGate((policy) => policy.canModerate)` puts a family on another rung. An owned-child edge (gardeneria's observation → disorder) passes a gate that reads its owned end's owner, typically `requireWriter` from `./subtype`.
417
+
418
+ On top of the port, pulpit enforces three rules:
419
+
420
+ - **Endpoint visibility** (`EDGE-001`). Each end declares its endpoint leaf's `access` and its current-version view (`nodes`; the leaf's own `LeafStore` fits). An edge is readable only if the viewer can read the node at every end, by the rule that leaf's own `get` and `list` apply, a reference leaf's followers arm included. `get` fails `EdgeNotFound` for a hidden edge, and `list` omits it, never `Forbidden` (`SEC-AUTH-ENUM-001`). A related list (the edges at one node) is `list` filtered on that end's column, so it omits every edge whose other end is hidden, and the related list of a hidden node is empty. The list filter (`endpointFilter`) and the per-endpoint predicate (`canViewEndpoint`) read the same facts, so they never disagree. A create naming a node the viewer can't read fails `NodeNotFound`, before the write gate runs, so a 403 never confirms that the node exists.
421
+ - **Endpoint lifecycle.** A tombstoned end hides its edges from every viewer below the view-deleted rung, and a create can't name it, whoever asks. The view-deleted rung reads a tombstoned end as the leaf's own `get` reads the tombstone, by its access rule ([#93](https://github.com/thomasfosterau/pulpit/issues/93)), so it can `list`, `get` and `delete` an edge whose only hidden end is tombstoned: a moderator can find and remove a retired node's stale edges. An end hidden for any other reason (a `private` node below the view-private rung, say) still hides the edge from that rung. Nothing is deleted, so restoring the node (`restore`) brings its edges back as they were.
422
+ - **Cardinality.** An end's `maxEdges` bounds how many edges one node at that end may hold; a create past it fails `EdgeCardinalityExceeded` (with the end, the node and the limit). It must be a positive safe integer: `defineEdgeOps` throws `EdgeDeclarationError` when the family is declared otherwise, and likewise for an end alias containing a `.`. A create counts only the edges at the node that the caller can read and whose every end is live ([#77](https://github.com/thomasfosterau/pulpit/issues/77), [#93](https://github.com/thomasfosterau/pulpit/issues/93)). A re-assertion of a stored edge resolves before the count, so it stays idempotent at a full node. Counting that way has three consequences:
423
+ - **A refusal discloses nothing hidden**; see [What a write discloses](#what-a-write-discloses).
424
+ - **A tombstoned partner frees its slot at once.** Its edge stays stored and stops counting, so tombstoning a partner never freezes a node.
425
+ - **The limit holds per caller, and can be overshot.** Edges a writer can't read don't count against that writer, so a node can end up holding more than `maxEdges` edges in all. Restoring a tombstoned partner brings its edge back without a check (a restore is a chain write and reads no edge), which can put a node past its limit. An over-limit node keeps its edges, and every create at it fails `EdgeCardinalityExceeded` until enough are deleted. Only a caller who can read every edge at the node (the view-private rung, typically) is held to the limit over all of them; an edge to a tombstoned end never counts, for anyone.
426
+
427
+ - **Validity intervals** ([#210](https://github.com/thomasfosterau/pulpit/issues/210)). A family may declare `validity: { from, to }`, two edge columns holding when the edge starts and stops holding: a `Date`, or `NULL` for open, over `[from, to)`. A create checks both bounds are dates or `NULL` and that `from` is not after `to` (`InvalidValidity`, before the store is read). `maxEdges` then counts only the edges at the node whose interval overlaps the new one, so an office held by one person at a time takes a successor once the last holding ends, and refuses one that overlaps it. `validAt(validity, at)` is the list filter for the edges valid at an instant (`from IS NULL OR from <= at`, and `to IS NULL OR to > at`), passed to `list` as `where`. The two columns must differ, and the store must offer `closeValidity`; `defineEdgeOps` throws `EdgeDeclarationError` otherwise.
428
+
429
+ ### What a write discloses
430
+
431
+ The read operations never reveal a hidden edge (`SEC-AUTH-ENUM-001`), and nor does a write's answer ([#93](https://github.com/thomasfosterau/pulpit/issues/93)). `create` runs in this order: ends, then the write gate, then the unique-key collision, then the cardinality count. A node the caller can't read is `NodeNotFound` whoever asks, and only a caller the gate admits reaches the collision and the count. There:
432
+
433
+ - **The count reads no hidden edge.** `EdgeCardinalityExceeded` depends only on the edges the caller can read at the node, so a node holding hidden edges answers exactly as one holding none. The cost is the weaker limit under Cardinality: a writer who can't read every edge at a node can take it past `maxEdges` in all.
434
+ - **A collision says only that the key is taken.** `EdgeConflict` carries only the family's type. Under `"fail"` a collision with a hidden edge answers exactly as one with a readable edge; under `"ignore"` only a readable, live edge resolves to `{ created: false, id }`, and otherwise (a hidden edge, or one with a tombstoned end, whoever asks) it is `EdgeConflict`. That the key is taken is the unique index's own answer, so it can't be hidden. That says something about a hidden edge only in a family whose unique key leaves an end out (gardeneria's page slugs key on the path, not the page): there, a writer who gets `EdgeConflict` with no readable edge holding the key learns that some edge it can't read does, though not which, nor at which node. Where the key covers every end, a colliding edge joins the same nodes the create just showed to be live and readable, so it is readable itself. A family that must not reveal even that should key on every end.
435
+
436
+ An owned leaf's `./subtype` writes answer differently, by decision (pulpit#24): its write and history gates deliberately confirm to a signed-in caller that an owned row exists (`Forbidden` for one they don't own, `NodeNotFound` for none). A reference leaf's hide a row as `get` does (pulpit#101). See [SECURITY.md → Existence disclosure by the write gates](https://github.com/thomasfosterau/pulpit/blob/trunk/SECURITY.md#existence-disclosure-by-the-write-gates).
437
+
438
+ **An edge isn't a node.** It has no version chain, so the chain's append-only rules (`VERSION-CHAIN-001/002`) don't govern it, and its hard delete rewrites no version row. Tombstoning a node leaves its edges stored.
439
+
440
+ ### Taxonomies
441
+
442
+ pulpit ships no category or tag vocabulary: a taxonomy is a reference-data leaf plus an edge family.
443
+
444
+ - **The terms** are a `reference` leaf (`defineSubtype` with `access: { kind: "reference" }`), written at the corpus rung, versioned like any other node. A hierarchy is a parent column on that leaf, or a second edge family between terms.
445
+ - **An assignment** is an edge family whose ends are the term leaf and the classified leaf. `maxEdges` on the classified end says how many terms one node may hold (1 for a single-choice taxonomy); on the term end, how many nodes one term may classify. `onConflict: "ignore"` makes re-assigning a term idempotent.
446
+ - **Who may assign** is the write gate: the corpus rung for curated assignments, or a gate on the classified node's owner when members classify their own rows.
447
+ - **A node's terms, and a term's nodes,** are `list` filtered on one end. Either list hides what the viewer can't read at the other end, and a tombstoned term drops out of every list below the view-deleted rung until it is restored.
448
+ - **Retiring a term** is tombstoning it. Its assignments stay stored and stop counting towards the classified end's `maxEdges` ([#77](https://github.com/thomasfosterau/pulpit/issues/77)), so in a single-choice taxonomy the nodes it classified can be reassigned at once. The view-deleted rung still lists the stale assignments and can delete them. Restoring the term brings them back, and a node reassigned meanwhile then holds two terms, past its limit; it keeps both until one is deleted, and refuses a further assignment meanwhile.
449
+
450
+ ```ts
451
+ import type * as Effect from "effect/Effect";
452
+ import { defineEdgeOps, type EdgeRow, type EdgeStore } from "@thomasfosterau/pulpit/edges";
453
+ import { eq } from "@thomasfosterau/pulpit/filter";
454
+ import {
455
+ ownedAccess,
456
+ requireWriter,
457
+ type AccessPolicy,
458
+ type FollowerSeam,
459
+ type LeafRow,
460
+ type LeafStore,
461
+ type Viewer,
462
+ } from "@thomasfosterau/pulpit/subtype";
463
+
464
+ const assignments = <Following, E, R>(seams: {
465
+ readonly terms: LeafStore<LeafRow, Following, E, R>;
466
+ readonly notes: LeafStore<LeafRow, Following, E, R>;
467
+ readonly edges: EdgeStore<EdgeRow, Following, E, R>;
468
+ readonly viewer: Effect.Effect<Viewer, never, R>;
469
+ readonly policy: AccessPolicy;
470
+ readonly followers: FollowerSeam<Following, R>;
471
+ }) =>
472
+ defineEdgeOps({
473
+ type: "note-terms",
474
+ ends: {
475
+ term: { column: "termId", type: "terms", access: { kind: "reference" }, nodes: seams.terms },
476
+ note: {
477
+ column: "noteId",
478
+ type: "notes",
479
+ access: ownedAccess({ draft: true }),
480
+ nodes: seams.notes,
481
+ maxEdges: 10, // at most ten terms on one note
482
+ },
483
+ },
484
+ edges: seams.edges,
485
+ viewer: seams.viewer,
486
+ policy: seams.policy,
487
+ followers: seams.followers,
488
+ values: (input: { readonly termId: string; readonly noteId: string }) => ({ ...input }),
489
+ toResource: (row) => ({ id: row.id, termId: row["termId"], noteId: row["noteId"] }),
490
+ onConflict: "ignore",
491
+ // A member classifies their own notes; a moderator anyone's.
492
+ writeGate: ({ viewer, policy, edge }) => {
493
+ const noteId = String(edge["noteId"]);
494
+ return requireWriter(
495
+ ownedAccess(),
496
+ viewer,
497
+ policy,
498
+ { type: "notes", id: noteId },
499
+ seams.notes.ownerOf(noteId),
500
+ );
501
+ },
502
+ });
503
+
504
+ // A note's terms: the edges at one end, without any the viewer can't read.
505
+ const termsOf = <Following, E, R>(
506
+ ops: ReturnType<typeof assignments<Following, E, R>>,
507
+ noteId: string,
508
+ ) => ops.list({ limit: 50, offset: 0, where: eq("noteId", noteId) });
509
+ ```
510
+
511
+ | Export | What it is |
512
+ | --- | --- |
513
+ | `defineEdgeOps(definition)`, `EdgeDefinition`, `EdgeOps` | The factory, its declaration (type, ends, store, viewer seams, create mapping, conflict policy, write gate, row → resource mapper) and the operations it produces. |
514
+ | `EdgeEnd`, `EdgeEnds`, `EndpointView` | One end (its edge column, endpoint type and access, the endpoint view, `maxEdges`), the ends by alias, and the endpoint view a `LeafStore` satisfies. |
515
+ | `canViewEndpoint`, `endpointFilter`, `endpointColumn(alias, column)` | The per-endpoint read predicate, the same facts as a list filter over every end, and the filter column naming an end's node column. |
516
+ | `EdgeWriteGate`, `EdgeWriteRequest`, `corpusWriteGate`, `rungGate(rung)` | The write gate, what it is asked about, the default corpus-rung gate, and a gate on any rung. |
517
+ | `EdgeStore`, `EdgeRow`, `EdgeValues`, `EdgeListRequest`, `EdgeWrite` | The edge-table seam, its row and column values, the list request, and a create's outcome. |
518
+ | `EdgeNotFound`, `EdgeConflict`, `EdgeCardinalityExceeded` | A missing or hidden edge (404), an unresolved unique-key collision (409, never naming the stored edge), and a create past a declared limit (409). |
519
+ | `EdgeValidity`, `validAt(validity, at)`, `CloseValidity`, `InvalidValidity` | A family's validity interval columns, the list filter for the edges valid at an instant, the store's interval-closing method, and a malformed or widening interval (422). |
520
+ | `EdgeDeclarationError` | A malformed family (an end alias with a `.`, a `maxEdges` that isn't a positive safe integer, a `validity` naming one column twice, or a `validity` over a store without `closeValidity`), thrown by `defineEdgeOps` when the family is declared, never as an Effect failure. |
521
+
522
+ ## `./proposals`
523
+
524
+ Suggested edits (pulpit#49). A contributor proposes a patch to a node they can read; the node's writer accepts it (appending it as a new version) or rejects it, and the proposer may withdraw it while it is pending. A proposal is a plain row, not a node: the target, the patch as submitted, the `baseVersion` it was written against (stamped from the target's head, never client-supplied), its status (`pending | accepted | rejected | withdrawn`), the decider and the timestamps.
525
+
526
+ ```ts
527
+ import * as Effect from "effect/Effect";
528
+ import * as Option from "effect/Option";
529
+ import { defineProposals } from "@thomasfosterau/pulpit/proposals";
530
+
531
+ const pages = defineProposals({
532
+ type: "pages",
533
+ target: pageOps, // the leaf's defineSubtype operations
534
+ chain, // its chain store: the head a proposal is stamped with
535
+ ownerOf: pageLeaves.ownerOf,
536
+ proposals: proposalStore,
537
+ viewer,
538
+ policy,
539
+ canPropose: () => true, // any signed-in account (gardeneria); website: contributor+
540
+ attributes: ["title", "body"], // the leaf's update fields, in declaration order
541
+ decode: decodePageUpdate, // the leaf's update decoder over a patch's values
542
+ });
543
+
544
+ const program = Effect.gen(function* () {
545
+ const { id } = yield* pages.submit({
546
+ targetId,
547
+ patch: { title: "Better" },
548
+ rationale: Option.none(),
549
+ });
550
+ const review = yield* pages.review(id); // { baseState: "clean" | "moved", conflict: { state, conflictingAttributes } }
551
+ return yield* pages.accept(id, { note: Option.some("thanks") }); // the { id, version } it appended
552
+ });
553
+ ```
554
+
555
+ - **Classification comes in two tiers.** `classifyProposalBase(base, head)` is `clean` or `moved` from two version numbers, with no read, so `get` and `list` give it for every pending row. `classifyProposalConflict(baseSnapshot, headSnapshot, touchedKeys)` is `clean`, `stale` (the target moved, but no touched attribute changed) or `conflicting`, over `diffStates`; it needs two version reads, so only `review` and `accept` run it. A touched attribute either state lacks (kept outside the chain) counts as conflicting on a moved target: fail closed.
556
+ - **A localised leaf** ([#184](https://github.com/thomasfosterau/pulpit/issues/184)). Declare `localeAttributes` (the translated columns a proposal may touch: the leaf's locale level's `columns`, or fewer), and `submit` takes `locales`, a per-locale patch beside `patch`, stored as submitted in the row's `locales` and accepted through the same `update` as one version. `classifyProposalConflict(base, head, touchedKeys, touchedLocales)` reads each touched locale from the two versions' locale levels: a touched translated column conflicts when it changed or either version lacks it, every touched column of a locale added or dropped in between conflicts, and a locale neither version carries (a new translation) does not. The verdict's `conflictingLocales` and `ProposalConflict`'s `locales` name them by locale. A proposal may add a translation but never drop one: a `null` locale is refused, since removing a locale is the owner's.
557
+ - **The patch rules** (`validateProposalPatch`, at submit and again at accept): an object with at least one key; no `PROPOSAL_FORBIDDEN_KEYS` (`id`, `type`, `visibility`, `draft`, `publishedAt`, plus the leaf's own `forbidden`), checked before unknown keys; no key outside `attributes` (refused, never dropped); then the leaf's `decode`. A per-locale patch follows the same rules per locale, against `localeAttributes`, with a non-empty locale tag and no `null` (and the shared patch may then be `{}`). A failure is `InvalidProposalPatch` (422).
558
+ - **Accept appends through the target's own `update`** (`PUB-003`), as the account that accepted, with the head it classified against as `expectedVersion` and the proposer as the version's `proposedBy`; a conflicting proposal fails `ProposalConflict` (409) and writes nothing, and an edit landing between the classification and the append fails `StaleVersion`. The append and the proposal's close run in the caller's transaction, so they commit together. It runs the target's before hooks but not `subtypeHandlers`' cache write-through: an application that caches the target writes the accepted version through itself.
559
+ - **Gates.** `submit`: signed in (`Unauthorized`), at `canPropose` (`Forbidden`), and able to `get` the target (`NodeNotFound` otherwise, never `Forbidden`). `review`, `accept`, `reject`: the target's write gate (`requireWriter`) before the proposal's status is disclosed; `review` and `accept` then read the versions through the target's gated `compare`. A proposal whose target was deleted can still be rejected. `withdraw`: its author (`Forbidden` for anyone else). A decided proposal fails `ProposalClosed` (409). `get` / `list`: the moderate rung reads every proposal; any other signed-in account reads its own and those on targets it owns; the anonymous read none, and a proposal the viewer can't read is `ProposalNotFound`.
560
+
561
+ A [write budget](#budgets) (pulpit#199) throttles `submit` when the definition passes one as `budget`. Notifications and the wire shape (a JSON:API `proposals` resource) are the application's.
562
+
563
+ | Export | What it is |
564
+ | --- | --- |
565
+ | `defineProposals(definition)`, `ProposalsDefinition`, `ProposalOps` | The factory, its declaration and the operations (`submit`, `get`, `list`, `review`, `accept`, `reject`, `withdraw`). |
566
+ | `ProposalStore`, `ProposalRow`, `ProposalListing`, `ProposalDecision` | The table seam (see [Seams](#seams)), its row, a read's row with the target's head version, and the write that closes a pending row. |
567
+ | `ProposalStatus`, `ProposalView`, `ProposalReview` | The status vocabulary, and a read's and a review's result. |
568
+ | `classifyProposalBase`, `classifyProposalConflict`, `ProposalBaseState`, `ProposalConflictState`, `ProposalConflictVerdict`, `TouchedLocales` | The two tiers of classification and their verdicts, and the translated columns a proposal touches by locale. |
569
+ | `validateProposalPatch`, `ValidatedPatch`, `PROPOSAL_FORBIDDEN_KEYS` | The patch rules, a patch that passed them, and the keys no proposal may carry. |
570
+ | `ProposalNotFound`, `ProposalClosed`, `ProposalConflict`, `InvalidProposalPatch` | The failures: 404, 409 (already decided), 409 (the target changed underneath), 422. |
571
+
572
+ ## `./blobs`
573
+
574
+ File and media leaves (pulpit#55). A file is an ordinary leaf (owned, like a member's image, or reference material) whose version row carries `fileResourceFields`: the `contentHash` of its bytes, their `byteSize` and `mediaType`, the original `filename`, and `bytesPending`. The bytes live behind a `BlobStore` the application supplies (R2, S3), under `blobKey(contentHash)`, so a version's bytes are immutable. New bytes are a new hash and a new version, `restoreVersion` restores the reference to the old bytes, and equal bytes are stored once.
575
+
576
+ ```ts
577
+ import * as Effect from "effect/Effect";
578
+ import * as Option from "effect/Option";
579
+ import { defineFileWrites, fileUrl } from "@thomasfosterau/pulpit/blobs";
580
+
581
+ const files = defineFileWrites({ leaf: imageOps, blobs: r2, transact });
582
+ const upload = Effect.gen(function* () {
583
+ const written = yield* files.create({
584
+ id: Option.none(),
585
+ state: { visibility: "public" },
586
+ upload: { bytes, mediaType: "image/png", filename: "fern.png" },
587
+ });
588
+ // written.bytesPending: true when the post-commit byte write failed
589
+ return written;
590
+ });
591
+ fileUrl(row, r2); // none while the row's bytesPending is anything but false
592
+ ```
593
+
594
+ - **Bytes are written after the chain commits.** `create` and `replace`:
595
+ 1. hash the upload (`sha256Hex`);
596
+ 2. append the version through the leaf's own operations inside `transact`, with `bytesPending: true` unless the store already holds those bytes;
597
+ 3. put the bytes once `transact` has returned;
598
+ 4. append `bytesPending: false` over exactly that version.
599
+ - **A failed byte write leaves the node pending.** If the put, or its confirmation, fails, the node stays `bytesPending` and the write still succeeds, reporting `bytesPending: true`. `fileUrl` gives no URL for it, so no reader is pointed at missing bytes.
600
+ - **`confirmBytes({ id, version, contentHash })` finishes a pending version** once the bytes are there (a retry, or a client's signed upload). It never lands over a later version (`StaleVersion`).
601
+ - **Nothing garbage-collects bytes.** They stay for as long as any version references them; purge (pulpit#58) is the one removal path, and `BlobStore.delete` is there for it.
602
+ - **Usage is an edge family.** "Where is this file used" is answered by `./edges`, using `fileUsageEnds({ user, file })` as a family's ends, so `EDGE-001` hides every use whose content the viewer can't read. Maintaining the edges is the referencing leaf's job, or an after hook's. Key the edge table uniquely on `(user_id, file_id)` and declare `onConflict: "ignore"`.
603
+ - **The writes bypass `subtypeHandlers`.** They go through the leaf's operations, so its gates, preconditions and before hooks apply, but not the handlers' cache write-through and after hooks.
604
+ - **Stays in the app:** the adapters, image transforms, EXIF extraction and video streaming.
605
+
606
+ | Export | What it is |
607
+ | --- | --- |
608
+ | `BlobStore`, `BlobHead` | The byte-store seam (see [Seams](#seams)) and what it knows of one object. |
609
+ | `ContentHash`, `sha256Hex(bytes)`, `blobKey(hash)` | The content hash (SHA-256, 64 lowercase hex characters), its Web Crypto derivation, and the store key it gives. |
610
+ | `fileResourceFields`, `FileColumns` | The file version's attribute spread (all read-only) and its columns. |
611
+ | `defineFileWrites(definition)`, `FileWriteOps`, `Upload`, `FileWrite` | The commit-ordered writes (`create`, `replace`, `confirmBytes`), an upload, and what a write reports. |
612
+ | `fileUrl(file, blobs)` | A version's URL, or none while its bytes are pending. |
613
+ | `fileUsageEnds({ user, file })` | The usage index's edge ends (`userId`, `fileId`). |
614
+
615
+ ## `./paths`
616
+
617
+ Routable content (pulpit#57). A path claim lives on the root node: one path per content item. The locale is chosen when the resolved node is read, never by the path, so a slug translation is not a claim of its own (pulpit#56's model (b)). `routable(ops, { column, paths, chain })` wraps a leaf's `defineSubtype` operations, so that each write keeps the node's claim in step with the state it landed at:
618
+
619
+ ```ts
620
+ import { routable } from "@thomasfosterau/pulpit/paths";
621
+
622
+ const pages = routable(pageOps, { column: "slug", paths: pagePaths, chain });
623
+ const handlers = subtypeHandlers(pages, { transact, inTransaction, cache }); // the claims run inside `transact`
624
+
625
+ pages.resolve("about"); // { kind: "node", id, resource } | { kind: "redirect", id, path } | PathNotFound
626
+ ```
627
+
628
+ - **Claims follow the node's lifecycle.** A write that leaves the node live claims the path its state holds in `column` (a string; anything else claims nothing). A tombstone, or a state without a path, frees the claim. A restore (`restore` or `restoreVersion`) re-claims its path, or fails `PathTaken` (409) when another node holds it now. The claim runs after the append, in the same transaction, so a refused claim rolls the append back with it. The state is read from `chain` ungated, so a write that hides the row from its own writer still keeps its claim right.
629
+ - **A path change records an alias.** The old path resolves as a redirect to the canonical one, until another node claims it (a canonical claim takes over an alias). A revert promotes the node's own alias back.
630
+ - **Resolution is gated** (`SEC-AUTH-ENUM-001`). `resolve(path)` reads the node through the leaf's own `get`, so the path of a hidden node (private, a draft, a tombstone), and an alias to one, is `PathNotFound`, never a redirect. So is an alias whose node has no canonical path now.
631
+ - **What `PathTaken` discloses.** It names the path, never the holder, but it says that some node holds the path, whether or not the writer can read it, as a unique-key conflict does.
632
+
633
+ Free-form redirects (patterns, a 404 log) are the site's. The path's syntax is the attribute's schema (effect-schema-ext's `Ids.slug`, once it publishes).
634
+
635
+ | Export | What it is |
636
+ | --- | --- |
637
+ | `routable(ops, config)`, `RoutableOps`, `RoutableConfig` | A leaf's operations with path claims and `resolve`, and what it takes: the path column, the `PathStore` and the chain store. |
638
+ | `Resolution` | What a path resolves to: `{ kind: "node", id, resource }` or `{ kind: "redirect", id, path }`. |
639
+ | `PathStore`, `PathEntry` | The path-table seam and one stored path (its node, canonical or alias). |
640
+ | `PathTaken`, `PathNotFound` | Another node holds the path canonically (409); no node the viewer can read answers to it (404). |
641
+
642
+ ## `./collections`
643
+
644
+ Ordered collections (pulpit#193): positioned members of a parent node, such as a list's items, an album's media, a byline's authors or a menu's entries.
645
+
646
+ ```ts
647
+ import { defineCollection } from "@thomasfosterau/pulpit/collections";
648
+
649
+ const items = defineCollection({
650
+ type: "list-items",
651
+ parent: listOps, // the parent leaf's `defineSubtype` operations
652
+ members: itemOps, // the member leaf's
653
+ store: collectionStore,
654
+ mode: { kind: "mutable" }, // the default; or { kind: "versioned", column: "memberOrder", chain: listChain }
655
+ });
656
+
657
+ items.append(listId, itemId);
658
+ items.move(listId, itemId, 0);
659
+ items.list(listId, { limit: 50, offset: 0 }); // { rows: [{ memberId, index, resource }], total }
660
+ ```
661
+
662
+ - **Both models, one store** (owner decision on pulpit#193). A family picks its model:
663
+ - **`mutable`** (the default) keeps the order beside the chain, like an edge: a write changes the store and nothing else.
664
+ - **`versioned`** also appends a parent version through the parent leaf's own `update` on every write. The version records the whole order (the member ids, in order) in a declared parent column, so `VERSION-CHAIN-001` covers it and history shows each reorder. After the parent's `restoreVersion`, `resync(parentId)` replaces the store's order with the one the restored version records, keeping only the members the writer can read, as `append` does (the column is writable through the parent's own `update`). The column names every member, the ones a reader of the parent can't read included, so wrap the parent leaf's `toResource` with `hideRecordedOrder(column, toResource)` to keep it out of the parent's resource (`EDGE-001`). The parent's history still shows it, behind the write gate.
665
+ - **The gates.**
666
+ - A read passes the parent's read gate: a hidden parent is `NodeNotFound`, never `Forbidden` (`SEC-AUTH-ENUM-001`).
667
+ - Each member is read through the member leaf's own `getMany`. A member the viewer can't read is omitted from the page and from its total, as `EDGE-001` omits an edge with a hidden end, and `index` counts only the members shown.
668
+ - A write passes the parent's write gate (`authorize`), and the member it adds must be one the writer can read (`NodeNotFound` otherwise).
669
+ - **Bounded** (`SEC-INPUT-003`). A collection holds at most `MAX_COLLECTION_SIZE` (1000) members (`CollectionFull` past it), so every read and reorder reads the whole order once. Pages are clamped to `MAX_PAGE_LIMIT`.
670
+ - **Failures.** Adding a member twice is `AlreadyMember` (409); moving or removing one that isn't there is `NotMember` (404). `move` clamps the index to the order's bounds.
671
+
672
+ | Export | What it is |
673
+ | --- | --- |
674
+ | `defineCollection(definition)`, `CollectionDefinition`, `CollectionOps`, `CollectionMode`, `CollectionMember` | The factory, its declaration, the operations (`list`, `append`, `move`, `remove`, `resync`), the two models and a listed member. |
675
+ | `hideRecordedOrder(column, toResource)` | A parent leaf's `toResource` without a versioned family's order column (`EDGE-001`). |
676
+ | `CollectionStore`, `CollectionEntry`, `MAX_COLLECTION_SIZE` | The membership table seam (see [Seams](#seams)), one member's place and the size bound. |
677
+ | `AlreadyMember`, `NotMember`, `CollectionFull` | The failures: 409, 404, 409. |
678
+
679
+ ## `./moderation`
680
+
681
+ The moderation workflow (pulpit#51). The kernel owns the rules; the transport (a queue, running the classifier, an API-call ledger, moderator notifications) stays the application's. A leaf's moderation state is a column of its version row (`ModerationState`: `pending | approved | rejected`), so every decision is an appended version.
682
+
683
+ ```ts
684
+ import * as Effect from "effect/Effect";
685
+ import {
686
+ defineModeration,
687
+ moderatedColumns,
688
+ moderationHook,
689
+ } from "@thomasfosterau/pulpit/moderation";
690
+
691
+ const declaration = moderatedColumns(["body"]); // moderationState / moderatedAt / moderatedBy; an edit to body re-reviews
692
+ const comments = defineSubtype({
693
+ // …the leaf's declaration…
694
+ hooks: Effect.succeed({ before: [moderationHook(declaration, policy)], after: [] }),
695
+ });
696
+ const moderation = defineModeration({ target: comments, declaration, viewer, policy });
697
+ // moderation.moderate(id, "approved")
698
+ ```
699
+
700
+ - **The rules** (`moderatePatch`, run as `moderationHook`, a before hook on [`./subtype`'s lifecycle seam](#subtype), so every write path honours them):
701
+ - A create stores `pending`, and a create asking for another state fails `WriteRejected`.
702
+ - Only the moderate rung changes the state, along `isModerationTransition`: `pending → approved | rejected`, re-moderation between `approved` and `rejected`, and re-review back to `pending`. Anyone else's change fails `WriteRejected`, appending nothing. A patch echoing the current state changes nothing.
703
+ - A decision is stamped into the declared `moderatedAtColumn` and `moderatedByColumn`.
704
+ - An edit that changes one of the leaf's `reReviewOn` attributes sends an `approved` row back to `pending`.
705
+ - A `restoreVersion` below the moderate rung carries the current moderation columns over the restored version's, so an old approval never returns with old content.
706
+ - An owner withdraws their own content by deleting it, which the owned write gate admits in every state.
707
+ - **The decision**, `defineModeration(...).moderate(id, decision, precondition?)`, is the moderate rung's: it fails `Unauthorized` / `Forbidden` before anything is read, then appends through the target's own `update`.
708
+ - **The read gate** is opt-in, on owned leaves only: declare the state column as the leaf's `moderationColumn` (`ownedAccess({ moderation: true })`), and `get` / `getMany` / `list` show a `pending` or `rejected` row only to its author and the view-private rung, as they do a draft (pulpit#154). Without it, readers see every state the visibility axes admit, and the rule is the application's.
709
+ - **The review queue** is the leaf's pending rows, `reviewQueueFilter(viewer, policy, column?)` (pass it as a list request's `where`; `never` below the moderate rung), plus the open member reports.
710
+ - **Threshold grading**, `gradeScores(scores, thresholds, defaultThreshold?)`, is pure. It takes a classifier's report shaped like effect-ai-ext's `ModerationModel` output (`{ flagged, categoryScores }`; pulpit doesn't import it) and the leaf's per-category bars.
711
+ - A category with no bar is graded against `DEFAULT_MODERATION_THRESHOLD` (0.5).
712
+ - A score breaches only strictly above its bar, and an unassessed category (absent, never `0`) can't breach.
713
+ - The classifier's own flag is never overridden downward.
714
+ - The grade has a priority band (`low | medium | high | urgent`) from the worst breach.
715
+ - Classification runs from an application's after hook; acting on a grade is a `moderate` call.
716
+ - **Member reports**, `defineReports({ target, reports, viewer, policy, budget? })`, are plain rows over a `ReportStore`. An optional [write budget](#budgets) throttles `file`.
717
+ - `file` needs a signed-in account that can `get` the target. One that can't gets `NodeNotFound`, never `Forbidden`, and the answer never says whether others reported the node.
718
+ - `get` / `list` are for the reporter and the moderate rung.
719
+ - `resolve(id, "resolved" | "dismissed", note)` is the moderate rung's alone. A closed report fails `ReportClosed`, and acting on the node is a separate call.
720
+
721
+ Which rows a viewer below the moderate rung may _read_ in each state (gardeneria shows an unapproved comment to its author and the rung only) is still the application's read rule, as `PUB-005` records; `isPubliclyServeable` already refuses every state but `approved` from the cache.
722
+
723
+ | Export | What it is |
724
+ | --- | --- |
725
+ | `moderationHook(declaration, policy)`, `moderatePatch`, `isModerationTransition`, `ModerationDeclaration`, `moderatedColumns(reReviewOn)` | The transition rules as a before hook, as a pure function, and the transition table; the leaf's columns and re-review triggers, and the declaration with the conventional names. |
726
+ | `defineModeration(definition)`, `ModerationOps` | The decision (`moderate`). |
727
+ | `reviewQueueFilter(viewer, policy, column?)` | The queue's leaf half as a list filter. |
728
+ | `gradeScores`, `ModerationScores`, `ModerationGrade`, `ModerationPriority`, `DEFAULT_MODERATION_THRESHOLD` | Threshold grading and its shapes. |
729
+ | `defineReports(definition)`, `ReportOps`, `ReportStore`, `ReportRow`, `ReportResolution`, `ReportStatus`, `ReportNotFound`, `ReportClosed` | Member reports: the operations, the table seam (see [Seams](#seams)), its row, the closing write, the status vocabulary and the failures (404, 409). |
730
+
731
+ ## `./working-copy`
732
+
733
+ An editor's autosave buffer (pulpit#46). Nothing in it is a version, so a hundred autosaves leave no trace in history (`VERSION-CHAIN-001`); an explicit save appends one.
734
+
735
+ ```ts
736
+ import { defineWorkingCopy } from "@thomasfosterau/pulpit/working-copy";
737
+
738
+ const copies = defineWorkingCopy({ leaf: postOps, chain, store: workingCopies });
739
+ // copies.save(id, { title: "Draft" }) — overwrites this editor's copy, no version
740
+ // copies.commit(id) — one update, expecting the head the copy was based on
741
+ ```
742
+
743
+ - **One copy per (node, editor)**, so two editors never overwrite each other's unsaved work.
744
+ - **`save` merges tri-state** over the editor's copy. A new copy is based on the head.
745
+ - **`commit` appends the copy** through the leaf's `update`, expecting that base, and clears it. If an edit landed in between, it fails `StaleVersion` and keeps the copy, so a commit never overwrites work its editor didn't see. With no copy it fails `WorkingCopyNotFound`.
746
+ - **Every operation runs the leaf's write gate** (`authorize`), since a copy shows unpublished state.
747
+
748
+ | Export | What it is |
749
+ | --- | --- |
750
+ | `defineWorkingCopy(definition)`, `WorkingCopyOps` | The operations (`save`, `get`, `discard`, `commit`). |
751
+ | `WorkingCopyStore`, `WorkingCopy`, `WorkingCopyKey` | The buffer seam (see [Seams](#seams)), one copy, and its `(id, userId)` key. |
752
+ | `WorkingCopyNotFound` | `commit` with no copy (404). |
753
+
754
+ ## `./comments`
755
+
756
+ The comments family (pulpit#50). A comment is an ordinary owned leaf, built with `defineSubtype` with `./moderation`'s `moderationHook` among its hooks. Edits append versions, a delete tombstones, and its history is gated like any leaf's. `defineComments` wraps it with the comment's own rules.
757
+
758
+ ```ts
759
+ import { defineComments } from "@thomasfosterau/pulpit/comments";
760
+ import { moderatedColumns, moderationHook } from "@thomasfosterau/pulpit/moderation";
761
+
762
+ const moderation = moderatedColumns(["body"]);
763
+ // commentOps = defineSubtype({ …, access: ownedAccess({ moderation: true }), hooks: Effect.succeed({ before: [moderationHook(moderation, policy)], after: [] }) })
764
+ const comments = defineComments({
765
+ comments: commentOps,
766
+ leaves: commentLeaves, // the comment leaf's current-version view
767
+ targets: { posts: postOps, images: imageOps }, // each target leaf's own operations
768
+ viewer,
769
+ policy,
770
+ moderation,
771
+ authorColumn: "ownerId",
772
+ maxDepth: 1, // the default: one level of replies; 0 is flat
773
+ });
774
+ ```
775
+
776
+ - **Three read gates, all of them** (`SEC-AUTH-ENUM-001`). A comment reads to a viewer only when all three admit it; anything else is `NodeNotFound`, never `Forbidden`.
777
+ 1. **The comment leaf's own `get`**: its owned visibility, and a tombstone only to the view-deleted rung.
778
+ 2. **The moderation gate.** An unapproved comment reads only to its author and the moderate rung, so the target's owner does not see a pending comment on their own row. `canViewModerated` and `moderationListFilter` are this one rule in two readings and never disagree.
779
+ 3. **The target gate**, run through the target leaf's own `get`. A comment on a target the viewer can't read is missing to them: a private or followers-only target they aren't admitted to, or a tombstoned one. The comment's own author is no exception.
780
+ - **`listByTarget`** checks the target first (`NodeNotFound` for one the viewer can't read), then lists through the comment leaf's own list and the moderation gate. It lists either every comment on the target, the top-level ones only (`parentId: some(none)`), or one comment's replies.
781
+ - **Reply depth is `maxDepth`**, default `1`: `0` is flat, `N` nests `N` deep.
782
+ - A reply past it fails `CommentDepthExceeded`.
783
+ - A reply's parent must be a comment the viewer can read, on the same target.
784
+ - Each comment stores `targetType`, `targetId`, `parentId` and `depth`, all written by `create`.
785
+ - A `maxDepth` that isn't a non-negative safe integer throws `CommentDeclarationError` when the family is declared.
786
+ - **Stays outside the kernel:** the document grammar (prosekit-utilities), captcha and spam handling, and the UI. Reactions are [`./reactions`](#reactions).
787
+
788
+ | Export | What it is |
789
+ | --- | --- |
790
+ | `defineComments(definition)`, `CommentsDefinition`, `CommentOps`, `CommentCreate`, `CommentListRequest`, `CommentTarget` | The family, its declaration, the operations (`create`, `get`, `listByTarget`), their inputs, and a target leaf's read. |
791
+ | `canViewModerated`, `moderationListFilter`, `ModeratedColumns` | The moderation gate as a row predicate and as a list filter, and the columns it reads. |
792
+ | `CommentDepthExceeded`, `CommentDeclarationError` | A reply past `maxDepth` (422); a malformed `maxDepth`, thrown at declaration. |
793
+
794
+ ## `./purge`
795
+
796
+ Erasure (pulpit#58). A tombstone hides a node and keeps every version of its content. Where that content is personal data that must be erased rather than hidden, `purge` hard-deletes it. It is the one sanctioned exception to the append-only chain (`VERSION-CHAIN-001/002`) and to `DOC-001`.
797
+
798
+ ```ts
799
+ import { definePurge } from "@thomasfosterau/pulpit/purge";
800
+
801
+ const posts = definePurge({
802
+ type: "posts",
803
+ chain,
804
+ store: purgeStore, // the application's erasure (see Seams)
805
+ viewer,
806
+ canPurge: (account) => account.role === "admin",
807
+ transact,
808
+ blobs: r2, // optional: where unreferenced bytes are deleted from
809
+ cache, // optional: evicted past the purged head
810
+ });
811
+ // posts.purge(id) → { id, documents, blobs, bytesLeft }
812
+ ```
813
+
814
+ - **The whole chain goes.** Every version row on every level, a live node's or a tombstone's, with the node's identity and owner rows. So do the rows the other seams keep for it: working copies, proposals, publication facts, path claims and aliases, reports, and every edge with the node at an end. A purged id is `NodeNotFound`, and so is its history.
815
+ - **Shared content survives.** Documents and bytes are shared by hash. A purge drops the node's references, and deletes a document or bytes only when no version of any other node still references the hash.
816
+ - **Bytes go after the commit.** The erasure and the document deletes run inside `transact`. The bytes are deleted once it has returned, as `./blobs` writes them. A failed delete is logged and listed in the report's `bytesLeft` rather than failing a purge that has committed. Without a `blobs` store every unreferenced content hash is listed there. A file written with the same bytes between the commit and the delete loses them, so a deployment that can race that way confirms its `bytesLeft` and pending files after a purge.
817
+ - **A dedicated rung.** `canPurge` is checked before anything is read, so a caller below it gets `Unauthorized` or `Forbidden` for every id alike. Erasure is one step: a live node purges as a tombstoned one does.
818
+ - **The writes bypass `subtypeHandlers`** and the leaf's lifecycle hooks. Pass the leaf's `cache` so no cached copy outlives the erasure (`CACHE-AUTH-005`).
819
+
820
+ | Export | What it is |
821
+ | --- | --- |
822
+ | `definePurge(definition)`, `PurgeDefinition`, `PurgeOps` | The operation (`purge`) and what it binds. |
823
+ | `PurgeStore`, `ErasedReferences` | The erasure seam (see [Seams](#seams)) and the hashes an erased node referenced. |
824
+ | `PurgeReport` | What a purge did: the documents and bytes deleted, and the bytes left behind. |
825
+
826
+ ## `./search`
827
+
828
+ Hybrid search (pulpit#204): the parts of the apps' search pipeline that are not SQL. The ranked queries stay the application's, as [`SearchBranch`](#searchbranchfollowing-e-r-and-embeddingstoree-r-search-and-embeddings) seams; pulpit fuses them, lets a reranker reorder the result, and runs the embedding pipeline over seams.
829
+
830
+ ```ts
831
+ import { deepStrictEqual } from "node:assert";
832
+ import * as Effect from "effect/Effect";
833
+ import { always } from "@thomasfosterau/pulpit/filter";
834
+ import { hybridSearch } from "@thomasfosterau/pulpit/search";
835
+
836
+ const search = hybridSearch({
837
+ branches: [
838
+ { name: "text", search: () => Effect.succeed([{ id: "a" }, { id: "b" }]) },
839
+ { name: "vector", weight: 2, search: () => Effect.succeed([{ id: "b" }]) },
840
+ ],
841
+ });
842
+ // In an application, `scope` is the leaf's `searchScopeFilter(leaf, viewer, request)`.
843
+ const hits = Effect.runSync(search({ query: "tomato", scope: always, limit: 10 }));
844
+ deepStrictEqual(
845
+ hits.map((hit) => hit.id),
846
+ ["b", "a"],
847
+ );
848
+ ```
849
+
850
+ - **Fusion.** `fuseRankings(branches, { k?, limit?, perBranch? })` is reciprocal rank fusion: each branch's first `perBranch` distinct hits score `weight / (k + rank)` (rank from 1, `k` defaulting to `RRF_K`, 60, the apps' constant), a hit's score is the sum over the branches that ranked it, and `ranks` records each rank. The result is ordered by score, then by id in code-point order, and capped at `limit`. A branch's `weight` defaults to 1; one that is not a finite, non-negative number scores nothing. Every count is clamped to `[1, MAX_PAGE_LIMIT]`. Pure.
851
+ - **The reranker** (`RerankerSeam`, optional) is shown the query and the fused candidates and answers their ids in its order. `applyReranking` lets it reorder only: an id that was not a candidate is dropped, and a candidate it omits follows the ones it ranked, in fused order.
852
+ - **The branches.** `hybridSearch({ branches, reranker?, k?, perBranch? })` returns a function of `{ query, scope, limit }` that hands **every branch the same `scope`** (the leaf's `searchScopeFilter`), runs them one after another in the caller's transaction, fuses, reranks, and caps at `limit`. An empty query asks no branch. Feed the result to `hydrateSearchHits`, which re-reads it through the gated `getMany`. A branch must apply the scope in the query that ranks, the live-row rule included, so a hidden row is never ranked and never shifts another hit's rank (`SEC-AUTH-ENUM-001`). `./testing`'s `scopeCheckedBranch` wraps a branch and fails `SearchScopeLeak` on a hit outside the scope, so an application's suite can prove its own branches do.
853
+ - **Embeddings.** `enqueueEmbeddings(documents, embeddings)` queues a document on its first insert (`onDocumentInsert`, in the `put`'s transaction, so a rolled-back write queues nothing). `embedPending({ embeddings, documents, embedder, text, batchSize })` embeds one batch: one `pending` read, one `getMany`, one `Embedder.embed` call and one `write` (`PERF-DB-001`). A pending document that is missing, or whose `text` is none, is discarded rather than retried forever; an embedder answering with the wrong number of vectors fails `EmbeddingMismatch` and writes nothing. `sweepEmbeddings(pipeline, maxBatches)` runs batches until the queue drains or `maxBatches` have run: the daily sweep of the unembedded.
854
+
855
+ `/ask`-style retrieval stays application code over these reads.
856
+
857
+ | Export | What it is |
858
+ | --- | --- |
859
+ | `fuseRankings(branches, options?)`, `BranchRanking`, `RankedHit`, `FusedHit`, `FusionOptions`, `RRF_K` | Reciprocal rank fusion and its inputs, output and options. |
860
+ | `applyReranking(candidates, order)`, `RerankerSeam` | A reranker's reordering, which can't add or drop a hit, and the seam. |
861
+ | `hybridSearch(definition)`, `HybridSearchDefinition`, `SearchBranch`, `BranchRequest`, `SearchScopeLeak` | The branches under one scope, fused and reranked; the branch seam and its request; the failure `scopeCheckedBranch` raises. |
862
+ | `enqueueEmbeddings`, `embedPending`, `sweepEmbeddings`, `EmbeddingStore`, `Embedder`, `EmbeddingPipeline`, `EmbeddingBatch`, `EmbeddingMismatch` | The embedding pipeline, its seams, a batch's result and its one failure. |
863
+
864
+ ## `./budgets`
865
+
866
+ Write budgets (pulpit#199): every family that takes user content needs a write throttle, and gardeneria re-adds one per family (a count-then-insert in the write's transaction). A budget is declared once per family and action: at most `limit` writes per account in any window of `windowSeconds`.
867
+
868
+ ```ts
869
+ import * as Effect from "effect/Effect";
870
+ import { bindBudget, budgetHook, countingCounter } from "@thomasfosterau/pulpit/budgets";
871
+
872
+ const budget = bindBudget({
873
+ type: "comments",
874
+ budget: { limit: 10, windowSeconds: 3600 }, // ten comments an hour
875
+ counter: countingCounter(commentCounts), // count(*) over the family's own rows
876
+ policy,
877
+ });
878
+ const comments = defineSubtype({
879
+ // …the leaf's declaration…
880
+ hooks: Effect.succeed({ before: [budgetHook(budget)], after: [] }),
881
+ });
882
+ const proposals = defineProposals({ /* … */ budget }); // throttles submit
883
+ const reports = defineReports({ /* … */ budget }); // throttles file
884
+ ```
885
+
886
+ - **Spent inside the write.** `budgetHook(bound, actions?)` is a before hook on [`./subtype`'s lifecycle seam](#subtype): it spends one write on each create (or each of the `actions` it lists), so a write over budget appends nothing, and a localised write spends once. `./proposals`' `submit` and `./moderation`'s report `file` take the bound budget as an opt-in `budget` and spend it as the `submit` or `file` action once the request is valid, before the row is stored. A comment family throttles through its leaf's hooks.
887
+ - **A distinct refusal.** Over budget, the write fails `WriteRejected` with the reason `RATE_LIMITED`; `isRateLimited(error)` tells it apart from every other refusal, so the wire answers 429 rather than 422.
888
+ - **Who spends.** Each account has its own budget per family and action. An account the budget's `exempt` rung admits (`(policy) => policy.canModerate` by default) spends nothing, and neither does an anonymous viewer, whom the write's own gate refuses.
889
+ - **The window** is rolling: a write counts from the instant it was made until `windowSeconds` later. The clock is Effect's `Clock`, so a test fixes it with `TestClock`.
890
+ - **One counter seam, two shapes** (owner decision on [#199](https://github.com/thomasfosterau/pulpit/issues/199)). `countingCounter(store)` is gardeneria's count within the write's transaction: one indexed `count(*)` of the account's writes since the window opened, with the write's own row as the spend. It is exact but not atomic: two concurrent transactions can both count `limit - 1` and both write. A family that must hold its limit under concurrency fills `BudgetCounter.take` with an atomic counter instead (a Durable Object, a row taken `FOR UPDATE`); see [Seams](#seams).
891
+ - **A malformed budget** (a `limit` or `windowSeconds` that isn't a positive safe integer) throws `BudgetDeclarationError` when it is bound, never as an Effect failure.
892
+
893
+ | Export | What it is |
894
+ | --- | --- |
895
+ | `WriteBudget`, `bindBudget(definition)`, `BoundBudget` | The declaration (limit, window, exempt rung), its binding to a family's type, counter and policy, and the bound budget's `spend`. |
896
+ | `budgetHook(bound, actions?)` | The bound budget as a chain write's before hook. |
897
+ | `BudgetCounter`, `BudgetRequest`, `countingCounter(store)`, `WriteCountStore` | The counter seam and what it is asked; the count-within-the-transaction counter and the count it reads (see [Seams](#seams)). |
898
+ | `RATE_LIMITED`, `isRateLimited(error)` | The refusal's reason, and the test for it. |
899
+ | `BudgetDeclarationError` | A malformed budget, thrown by `bindBudget`. |
900
+
901
+ ## `./reactions`
902
+
903
+ Reactions (pulpit#196), after gardeneria's `social.reactions`: likes and their kin on any node.
904
+
905
+ ```ts
906
+ import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
907
+
908
+ const reactions = defineReactions({
909
+ type: "posts",
910
+ kinds: reactionKinds(["like", "celebrate", "helpful", "curious", "sympathise"]),
911
+ target: postOps, // the target leaf's `defineSubtype` operations: `get` and `getMany`
912
+ reactions: reactionStore,
913
+ viewer,
914
+ });
915
+
916
+ reactions.react(postId, "like"); // { replaced: Option<kind> }
917
+ reactions.tally(pageOfPostIds); // HashMap<postId, { counts, total }>
918
+ ```
919
+
920
+ - **A fact beside the node** (owner decision on pulpit#196). No version chain and no history: the store keys a reaction `(node, account)`, so an account has at most one per node. `react` with another kind replaces it (and returns the kind it replaced); `unreact` hard-deletes it.
921
+ - **The kinds are the family's.** `reactionKinds(kinds)` throws `ReactionDeclarationError` at import for no kind, an empty one or a repeated one; `react` with an undeclared kind fails `InvalidReactionKind` (422). The kernel holds no list of kinds.
922
+ - **The target's read gate on every operation** (`SEC-AUTH-ENUM-001`). `react` and `unreact` on a node the viewer can't read fail `NodeNotFound`, never `Forbidden`. `tally(nodeIds)` answers only for the nodes the viewer can read (a hidden or missing one has no key; a readable one with no reaction has a zero tally), in one `getMany` and one grouped store read (`PERF-DB-001`); more than `MAX_SET_VALUES` ids reads nothing (`SEC-INPUT-003`). `mine({ limit, offset })` is the viewer's own reactions, newest first, omitting one whose node is now hidden from them.
923
+ - **Account erasure** (pulpit#207) deletes a member's reactions through the store's `eraseAccount`; no reaction operation exposes it.
924
+ - **Block and mute** (pulpit#195) suppress interaction only: a family gates `react` on the social graph once it lands; a tally counts every account's reaction.
925
+
926
+ | Export | What it is |
927
+ | --- | --- |
928
+ | `defineReactions(definition)`, `ReactionsDefinition`, `ReactionOps`, `Tally` | The factory, its declaration, the operations (`react`, `unreact`, `tally`, `mine`) and a node's tally. |
929
+ | `reactionKinds(kinds)`, `ReactionKinds`, `ReactionDeclarationError` | The family's vocabulary and its import-time check. |
930
+ | `ReactionStore`, `ReactionRow` | The table seam (see [Seams](#seams)) and one stored reaction. |
931
+ | `InvalidReactionKind` | An undeclared kind: 422. |
932
+
933
+ ## `./social`
934
+
935
+ The social graph (pulpit#195), after gardeneria's `social.follows`: who follows, mutes and blocks whom.
936
+
937
+ ```ts
938
+ import { defineSocial, socialFollowers } from "@thomasfosterau/pulpit/social";
939
+
940
+ const social = defineSocial({ store: socialStore, viewer, accounts }); // `accounts`: the app's account visibility
941
+ const posts = defineSubtype({ …, followers: socialFollowers(socialStore) }); // `followers` rows, end to end
942
+
943
+ social.follow(accountId); // { changed }
944
+ social.silencedFilter("ownerId"); // the opt-in read filter: drop the authors the viewer muted or blocked
945
+ ```
946
+
947
+ - **A relationship is a fact** keyed `(kind, from, to)` for `follow`, `mute` and `block`: no history, asserting one twice changes nothing (`changed: false`), removing one hard-deletes it. Only the viewer writes their own (`Unauthorized` when anonymous); one with oneself is `SelfRelationship` (422).
948
+ - **Following is immediate** (owner decision on pulpit#195); follow requests are a later slice.
949
+ - **A block suppresses interaction only** (owner decision on pulpit#195). While either account blocks the other, neither may follow the other (`InteractionBlocked`, 403), and blocking ends both follows between them. The block check is part of the follow's write (the store's `addFollow`), and `addBlock` inserts the block and ends both follows in one step, both under one lock on the account pair, so a follow racing a block never stands across it. Other modules gate an interaction (a reaction, a reply, a mention) on `interactionAllowed(a, b)`. A block hides no content by itself: `silencedFilter(column)` is the opt-in read filter a feed or thread applies, `column NOT IN` the accounts the viewer muted or blocked (through `./filter`'s `notIn`; an unset author is kept, and the anonymous get `always`).
950
+ - **A mute is one-way and private**: it only feeds that filter.
951
+ - **Lists.** `followers` and `following` are public, newest first, `LIMIT`ed (`MAX_PAGE_LIMIT`), and shown through the application's `AccountVisibility` (a private profile, a suspended account), when it supplies one. With one, a subject the viewer can't see answers exactly as an account with no relationships, and `total` is none (the store's count would include accounts the seam hides), so a page may hold fewer than `limit` rows; without one, `total` is the store's. `muted` and `blocked` are the viewer's own.
952
+ - **The follower seam.** `socialFollowers(store)` is `./subtype`'s `FollowerSeam` over the graph, so a `followers` row is readable by its owner's followers, and stops being the moment the follow ends.
953
+ - **Account erasure** (pulpit#207) deletes every relationship from or to a member through the store's `eraseAccount`.
954
+
955
+ | Export | What it is |
956
+ | --- | --- |
957
+ | `defineSocial(definition)`, `SocialDefinition`, `SocialOps`, `SocialPageRequest`, `Related`, `RelatedPage` | The factory, its declaration, the operations, a page request, a listed account and a public list's page. |
958
+ | `socialFollowers(store)` | The follower seam over the graph. |
959
+ | `SocialStore`, `Relationship`, `RelationshipKind`, `AccountVisibility` | The relationship table seam (see [Seams](#seams)), one relationship, its kinds, and the application's account visibility. |
960
+ | `InteractionBlocked`, `SelfRelationship` | The failures: 403 and 422. |
961
+
962
+ ## `./testing`
963
+
964
+ In-memory fakes of the kernel's seams, for tests: an adapter's or an application's suite can run the real operations (`defineVersionChain`, `defineSubtype`, `defineEdgeOps`, …) over them without a database. Each fake implements its seam's contract as stated under [Seams](#seams), in memory and with no SQL, and enforces what the real table's schema enforces (a primary or unique key) and nothing the operations are responsible for. They are the fakes the kernel's own suites run against, so they are its reference semantics for each seam.
965
+
966
+ **Not for production.** Every fake keeps its rows in memory for the life of the value, persists nothing, never opens a transaction and has no concurrency control. The subpath isn't on the root export, so production code never pulls it in by accident.
967
+
968
+ ```ts
969
+ import { deepStrictEqual } from "node:assert";
970
+ import * as Effect from "effect/Effect";
971
+ import * as Option from "effect/Option";
972
+ import { defineSubtype, type LeafRow } from "@thomasfosterau/pulpit/subtype";
973
+ import {
974
+ makeInMemoryChainStore,
975
+ makeInMemoryFollowers,
976
+ makeInMemoryLeafStore,
977
+ } from "@thomasfosterau/pulpit/testing";
978
+
979
+ const chain = makeInMemoryChainStore();
980
+ const terms = defineSubtype({
981
+ type: "terms",
982
+ access: { kind: "reference" },
983
+ chain,
984
+ leaves: makeInMemoryLeafStore(chain, { columns: ["name"] }),
985
+ viewer: Effect.succeedSome({ id: "u-editor", role: "editor" }),
986
+ policy: {
987
+ canViewPrivate: () => false,
988
+ canViewDeleted: () => false,
989
+ canModerate: () => false,
990
+ canWriteCorpus: (account) => account.role === "editor",
991
+ },
992
+ followers: makeInMemoryFollowers([]), // who follows whom: [follower, [followed, …]]
993
+ toResource: (row: LeafRow) => ({ id: row.id, name: row["name"] }),
994
+ });
995
+
996
+ const program = Effect.gen(function* () {
997
+ const { id } = yield* terms.create({ id: Option.none(), state: { name: "Ferns" } });
998
+ return yield* terms.get(id);
999
+ });
1000
+ deepStrictEqual(Effect.runSync(program).name, "Ferns");
1001
+ ```
1002
+
1003
+ | Export | Fakes | Test hooks |
1004
+ | --- | --- | --- |
1005
+ | `makeInMemoryChainStore()`, `InMemoryChainStore` | [`VersionChainStore`](#versionchainstoree-r-the-version-chain-persistence), with the `(id, version)` key (`VersionConflict`) and sequential UUID-shaped `mintId`s. | `rows()` (every appended row), `dropState(id, version)` (a partial chain), `raceNextAppend(id)` (a concurrent writer), `erase(id)` (the purge fake's hard delete). |
1006
+ | `makeInMemoryLeafStore(chain, { columns, now?, localised? })`, `InMemoryLeafStore`, `InMemoryLeafStoreOptions` | [`LeafStore`](#leafstorerow-following-e-r-the-current-version-view), as the current-version view over an in-memory chain plus the owner stamps; `list` evaluates the `ListFilter` in memory and sorts by the list order contract (`NULLS LAST`, code-point strings, `id` last). With `localised: true`, the view gains `locale` and `list` reads one row per `(id, locale)`. `distinct` reads the same rows `list` does. | `owners()` (the stamps), `dropOwner(id)` (an ownerless owned node). |
1007
+ | `makeInMemoryDocumentStore()`, `InMemoryDocumentStore` | [`DocumentStore`](#documentstoredocument-e-r-the-content-addressed-document-store), hashing exactly as the hash contract says (SHA-256 over `./documents`' `canonicalText`, pinned to hashes taken from Postgres). | `stored()` (every document by hash), `writes()` (how many `put`s inserted), `remove(hashes)` (the purge fake's delete). |
1008
+ | `makeInMemoryEdgeStore({ ends, uniqueKey, now? })`, `InMemoryEdgeStore`, `InMemoryEdgeStoreOptions` | [`EdgeStore`](#edgestorerow-following-e-r-the-edge-table), with the unique key (`NULLS NOT DISTINCT`), the INNER JOIN of each end's view, and the window-column total. | `rows()` (every edge, hidden ones included), `raceNextInsert(winner)` (a concurrent writer). |
1009
+ | `makeInMemoryNodes(columns)`, `InMemoryNodes` | `EndpointView`, an end's view whose rows a test writes directly (an `InMemoryLeafStore` is one too). | `put(row)`. |
1010
+ | `makeInMemoryProposalStore({ chain, leaves })`, `InMemoryProposalStore` | [`ProposalStore`](#proposalstoree-r-the-proposal-table), reading each listing's head version from `chain` and its `targetOwnerId` from `leaves`' owner stamps; newest first, `id` descending on a tie. | `rows()` (every proposal as it now stands). |
1011
+ | `makeInMemoryBlobStore()`, `InMemoryBlobStore`, `InMemoryBlob`, `InMemoryBlobFailure` | [`BlobStore`](#blobstoree-r-the-byte-store), with `memory://<key>` URLs and no signed upload. | `objects()` (every stored object), `failNextPut()` (a byte write failing after the commit). |
1012
+ | `makeInMemoryPathStore()`, `InMemoryPathStore` | [`PathStore`](#pathstoree-r-the-path-table), one entry per path, with `claim`'s `PathTaken` and alias rules in one step. | `entries()` (every stored path and its entry). |
1013
+ | `makeInMemoryBudgetCounter()`, `InMemoryBudgetCounter` | An atomic [`BudgetCounter`](#budgetcountere-r-and-writecountstoree-r-the-write-budget-counter): it counts the spends it admitted in the window and records one only when it answers `true`. | `spends()` (every admitted spend). |
1014
+ | `makeInMemoryWriteCountStore()`, `InMemoryWriteCountStore`, `RecordedWrite` | [`WriteCountStore`](#budgetcountere-r-and-writecountstoree-r-the-write-budget-counter), counting the recorded writes at or after `since`. | `record(write)` (the row a write inserts). |
1015
+ | `makeInMemoryReportStore()`, `InMemoryReportStore` | [`ReportStore`](#reportstoree-r-the-member-report-table), evaluating the filter in memory, newest first with `id` descending on a tie. | `rows()` (every report as it now stands). |
1016
+ | `makeInMemoryResourceCache(serveable)`, `InMemoryResourceCache` | [`ResourceCacheSeam`](#resourcecacheseam-and-the-serveable-hook-the-resource-cache), with compare-on-set: a `set` below the highest version held for an id is a no-op, and a `delete` raises it. | `get(id)` (what it would serve), `versionOf(id)` (the version held, set or evicted). |
1017
+ | `makeInMemoryPublication({ chain, leaves, now? })`, `InMemoryPublication` | [`PublicationStore`](#publicationstoree-r-and-liveviewrow-following-e-r-the-live-pointer) and `LiveView` over an in-memory chain and leaf store, plus the gated leaf's current-version view with `draft` computed from the log. | `recorded()` (every fact). |
1018
+ | `makeInMemoryWorkingCopyStore()`, `InMemoryWorkingCopyStore` | [`WorkingCopyStore`](#workingcopystoree-r-the-autosave-buffer), one copy per `(id, userId)`. | `copies()`. |
1019
+ | `makeInMemoryPurgeStore({ chains, documents?, documentColumns?, blobColumns?, dependents? })`, `InMemoryPurgeStoreOptions` | [`PurgeStore`](#purgestoree-r-erasure), erasing a node's rows from every chain (and, through `dependents`, from other fakes) and reading a hash as referenced while any remaining row carries it in a declared column. | |
1020
+ | `makeInMemorySearchBranch({ name, weight?, rows, score, now })` | [`SearchBranch`](#searchbranchfollowing-e-r-and-embeddingstoree-r-search-and-embeddings), ranking the rows the scope admits by `score` (best first, ties by id). | |
1021
+ | `scopeCheckedBranch(branch, { rows, now })` | Wraps any branch; fails `SearchScopeLeak` on a hit whose row the scope doesn't admit or `rows` lacks. | |
1022
+ | `makeInMemoryEmbeddingStore()`, `InMemoryEmbeddingStore` | [`EmbeddingStore`](#searchbranchfollowing-e-r-and-embeddingstoree-r-search-and-embeddings), a queue in insertion order (idempotent `enqueue`; a hash with a vector is never queued again). | `queued()`, `vectors()`. |
1023
+ | `makeInMemoryReactionStore()`, `InMemoryReactionStore` | [`ReactionStore`](#reactionstoree-r-the-reaction-table), keyed `(nodeId, accountId)`: `upsert` replaces, `tally` groups by node and kind, `byAccount` is newest first. | `rows()`. |
1024
+ | `makeInMemoryVersionedKv()`, `InMemoryVersionedKv` | [`VersionedKv`](#versionedkve-r-the-caches-key-value-store), comparing and writing in one step; an entry past its TTL reads as absent (the Effect `Clock`, so `TestClock` steers expiry). | `peek(key)`, `poke(key, entry)` (a corrupt value). |
1025
+ | `makeInMemoryCollectionStore()`, `InMemoryCollectionStore` | [`CollectionStore`](#collectionstoree-r-the-membership-table), each parent's entries replaced whole and read back by position, then member id. | `rows()`. |
1026
+ | `makeInMemorySocialStore()`, `InMemorySocialStore` | [`SocialStore`](#socialstorefollowing-e-r-the-relationship-table), keyed `(kind, from, to)`; `followingOf` and `silencedBy` are `HashSet`s. | `rows()`. |
1027
+ | `makeInMemoryFollowers(table)`, `InMemoryFollowing` | [`FollowerSeam`](#the-viewer-the-role-axes-and-the-follower-seam) over an explicit table of who follows whom; the `in` set is a `HashSet` of account ids, where SQL's is a subquery. | |
1028
+
1029
+ `now` is the instant the list filter's `published` / `notPublished` compare against (and, for the edge store, the base of the minted `createdAt`s). Pass a fixed `Date` for a deterministic suite; left out, each call reads the Effect `Clock`, so `TestClock` steers it. The role ladder and the `AccessPolicy` are the application's, so `./testing` ships none.
1030
+
1031
+ ## Seams
1032
+
1033
+ The kernel is written against explicit interfaces, so that it doesn't depend on the packages that fill them before they publish. Each seam below states the shape its real implementation must have, and which package (if any) is expected to fill it; the issue numbers in parentheses are for maintainers and aren't needed to follow the contract. [`./testing`](#testing) has an in-memory fake of each store and of the follower seam, which implements the contract stated here.
1034
+
1035
+ ### `VersionChainStore<E, R>`: the version-chain persistence
1036
+
1037
+ Expected to be a Drizzle store built over the application's version tables (effect-drizzle-ext's `./versioned-chain`, once it publishes; pulpit#4). Expected schema:
1038
+
1039
+ - a root version table (the apps' `core.node_version`) keyed `(id, version bigint)`, carrying `action` (`create | update | delete`, the single home of currency and tombstone state), `timestamp`, `user_id` (the row's `actorId`) and `proposed_by` (nullable: the row's `proposedBy`, the contributor whose accepted proposal the version applies; pulpit#49); plus the identity row (`core.node`: `id`, `type`) inserted at version 0;
1040
+ - one version table per chain level keyed `(id, version)` with the `(id, version)` → parent-version FK, carrying that level's domain columns (6NF fact tables for a fact-scoped level);
1041
+ - on a localised leaf (pulpit#56), the locale level: fact rows keyed `(id, version, locale)` with the `(id, version)` FK, carrying the translated columns. `append` inserts one per locale in `row.locales` (none when it is absent), and `snapshot` returns them as `locales`, keyed by tag, omitting the key for a version that carries none.
1042
+
1043
+ Method contract: `head` is `SELECT … ORDER BY version DESC LIMIT 1` on the root table; `snapshot` reads every level at `(id, version)` and returns none when ANY level is missing (no partial state); `append` inserts the root row and every level's row at the given version (at version 0 the identity row too) and maps a primary-key violation on `(id, version)` to `VersionConflict`; `entries` is `ORDER BY version ASC LIMIT $limit`; `mintId` is a UUID. `E` is the store's query error and `R` the ambient transaction (`EffectDrizzleQueryError` and `CurrentTransaction` for the Drizzle store). The store never opens a transaction itself.
1044
+
1045
+ ### `LeafStore<Row, Following, E, R>`: the current-version view
1046
+
1047
+ Expected to be a Drizzle store over the leaf's current-version view (pulpit#5). Each row is a `LeafRow`: the node's `id`, the head row's `version` (a `bigint`, pulpit#65) and `action`, and the leaf's columns. The view projects `version` itself, from the same head row it reads `action` from, so a read costs no second query against the chain. `id`, `version` and `action` are the view's own names: no leaf column may take one. `columns` is the view's column-name set (`getColumns(view)`, which includes `version`); `get` / `getMany` are `id = $id` / `id = ANY($ids)` reads with `LIMIT`; `list` renders its `ListFilter` (below), orders totally by its `orderBy` (below), and returns the page plus the unpaged total as a window column; `distinct({ column, where, limit, offset })` (pulpit#186) is `SELECT DISTINCT <column> … WHERE <where> AND <column> IS NOT NULL ORDER BY <column> COLLATE "C" LIMIT … OFFSET …` with the distinct count as `total` (a `NULL` or non-string value is no value), over the same rows `list` reads; `ownerOf` / `stampOwner` read and insert the node's owner row (`core.node_owner`).
1048
+
1049
+ **A localised leaf's view** (pulpit#56) is one row per `(id, locale)` the head carries: the locale level's row for that version, left-joined to the shared view, with the tag in a `locale` column (`LOCALE_COLUMN`). A node whose head carries no locale is one row with a `NULL` locale. `columns` includes `locale`; `list` reads these rows, and renders the `locale` filter arm over that column. `get` / `getMany` read the shared row, one per node, without the locale level. A publish-gated localised leaf's `LiveView.list` reads the same shape at the live version.
1050
+
1051
+ The list order contract (pulpit#112). `orderBy` arrives checked: every column is sortable (in the leaf's `sortable` list or, without one, in `columns`), every direction is `asc` or `desc`, and no column appears twice. Empty means `id` ascending alone. The store renders it as
1052
+
1053
+ ```sql
1054
+ ORDER BY <column> ASC|DESC NULLS LAST, …, id ASC
1055
+ ```
1056
+
1057
+ - **`NULLS LAST` in both directions**, spelled out on every key, because PostgreSQL defaults to `NULLS FIRST` for `DESC`.
1058
+ - **`id ASC` closes every order, in byte order**, so no two rows tie and an `OFFSET` page never skips or repeats a row while the data holds still. A `uuid` id already sorts by its bytes; a text id needs `id COLLATE "C" ASC`, as the fake sorts ids by code point. Pagination is by `LIMIT` / `OFFSET`; there is no cursor to carry the order key.
1059
+ - **A sort key's column holds one scalar orderable type**: text, numeric, boolean, timestamp or bigint. `json` / `jsonb` and array columns are outside the contract: PostgreSQL refuses to order `json`, and orders `jsonb` by its own rules, which the fake doesn't reproduce. An adapter must not offer such a column for sorting, and a leaf's `sortable` list must not name one.
1060
+ - **A string key sorts with `COLLATE "C"`** (code-point order), as the filter's comparisons do. A store that sorts under another collation must accept that its order can differ from the in-memory fake's. A `Date` sorts by its instant, a number or `bigint` by value, and `false` before `true`; the fake places `NaN` after every number, as PostgreSQL's `float8` does.
1061
+ - **Timestamp precision.** PostgreSQL stores a `timestamptz` to the microsecond and a JavaScript `Date` holds milliseconds, so two rows the fake sees as tied (the same millisecond, ordered by `id`) can be ordered by their microseconds in SQL. A test that needs both to agree should seed timestamps whole to the millisecond.
1062
+ - **The order never changes membership.** It is applied after `where`, and `total` is the same under every order.
1063
+
1064
+ `makeInMemoryLeafStore` in [`./testing`](#testing) follows this contract.
1065
+
1066
+ ### `ListFilter<Following>` → SQL: the filter renderer
1067
+
1068
+ The rendering contract is the table under [`./filter`](#filter). `Following` is the follower seam's subquery (Drizzle's `SQLWrapper`), rendered as `col IN (<subquery>)`, never a materialised array. `published` / `notPublished` use the database's `now()` and are parenthesised. A string comparison (`gt` / `gte` / `lt` / `lte`) uses the `"C"` collation, so it orders by code point as `evaluate` does; `anyOf` / `noneOf` bind their values as one array parameter.
1069
+
1070
+ ### The viewer, the role axes and the follower seam
1071
+
1072
+ Expected to come from effect-auth once it publishes (pulpit#5). Its default branch already has them, unpublished (release PR effect-auth#177): `OwnedAccess` (`canView`, `readScope`, the `IsFollower` service, read fail-closed, `serveableAxes`), `Guard` (`Unauthorized`, `Forbidden`, and the kit `Guard.make` returns: `requireRoleAtLeast`, `requireOwnerOrRole`), `Ladder` and `Cacheability.publiclyServeable`, plus `ownedListFilter` in `@thomasfosterau/effect-auth-drizzle`. Until it publishes, the application supplies:
1073
+
1074
+ - `Viewer = Option<{ id, role }>`: the request's signed-in account, or none for an anonymous request (effect-auth's session context).
1075
+ - `AccessPolicy`: four predicates over a signed-in account (`canViewPrivate`, `canViewDeleted`, `canModerate`, `canWriteCorpus`). Each compares the account's role against an application-named rung on its role ladder (effect-auth's `Ladder.make`, checked through the `Guard.make` kit's `hasRoleAtLeast` / `requireRoleAtLeast`). Each must fail closed on an unknown role.
1076
+ - `FollowerSeam<Following, R>`: `isFollower(followerId, ownerId)` answers one row's question, and `following(followerId)` returns the list filter's `in` set (effect-auth's `IsFollower`). `nobodyFollows(nobody)` is the fail-closed default when a runtime provides none (effect-auth's `isFollowerNever`).
1077
+ - **A reference leaf's followers column.** A reference leaf that carries `followers` rows declares `followersOfColumn` on its `LeafAccess`: a column of its current-version view (the `LeafStore`'s `columns` must include it) holding the id of the account that, with its followers, may read the row, in the same id space the follower seam takes. It is the application's column, under any name; pulpit adds no id brand for it. The list renders it as `visibility = 'followers' AND (<column> = <viewer> OR <column> IN (<following>))` and `get` shows the row when the column is the viewer's id or `isFollower(viewer, <column>)` answers true, so a `NULL` (or any non-string) value names nobody: it matches no viewer and is never asked of the seam. The named account and its followers read its `followers` rows; its `private` rows are read only at the view-private rung. A declared column the view lacks opens no `followers` row.
1078
+ - `ReadScope` and `canViewOwned` (effect-auth's `OwnedReadScope` / `OwnedAccess.readScope` and `OwnedAccess.canView`) live here until effect-auth publishes its own; then pulpit consumes its read scope and keeps only the rendering into `ListFilter`.
1079
+ - `Unauthorized` / `Forbidden` (effect-auth's `Guard` errors) are defined here until effect-auth publishes them.
1080
+
1081
+ ### `SubtypeHandlers`: the handler record
1082
+
1083
+ The record a JSON:API server's resource handlers are expected to accept. effect-jsonapi's server skeleton has shipped as its `ResourceHandlers` namespace (`make`, `readOnly`, `related`; exported since 0.15.0, and pulpit's peer is `>=0.17.0`), and `subtypeResourceHandlers(handlers, config)` wires the record onto `ResourceHandlers.make` (pulpit#5, below). One entry per endpoint (`list`, `get`, `create`, `update`, `delete`): the reads return the resource the document assembler wraps, and each write returns a `CommittedWrite`, the committed `{ id, version }` and the resource as its writer re-read it (`Option`), plus `restore` and `restoreVersion`, which have no JSON:API endpoint and are called directly. Each write runs inside the config's `transact` (always-open: a nested call opens a savepoint), re-reads the row outside it as the writer, and hands it to the leaf's after hooks, the cache write-through (`cacheWriteThrough`) first (see [`./subtype`](#subtype) → Lifecycle hooks). When that re-read fails, as it does for a write that hides the row from its own writer (a corpus writer below the view-private rung making a reference row `private`), the handler evicts the entry, so the cache never keeps the superseded resource (`CACHE-AUTH-005`, pulpit#107), and answers the committed write with no resource (`resource: Option.none()`) rather than a `NodeNotFound` for a write that committed. Any other re-read failure (a store error, a defect) evicts the same way and propagates unchanged. An interrupt can't come between a committed write and its write-through or eviction: once the operation has succeeded, a failure of `transact` (an interrupt included) evicts too, since the write may have committed, while a write the gate or the precondition refuses never touches the cache (should `transact` retry internally, only its last attempt counts). **On effect-jsonapi's `ResourceHandlers`** (pulpit#5). `subtypeResourceHandlers(handlers, config)` builds the five CRUD handlers `ResourceHandlers.make` returns, keyed by the endpoint names `Group.flat` declares, over the record. Its config is `make`'s (`segment`, `listQuery`, `linkBase`, and optionally `include`, `viewer`, `capabilities`, `maxPageLimit`) without `ops`, `cache` or `transact`, plus the resource's JSON:API `type` and three mappings from the endpoint's decoded inputs: `listRequest(query)` → `ListRequest`, `createInput(payload)` → `{ id, state }` and `updateInput(payload)` → `{ id, patch, expectedVersion? }`. Annotate the two payload parameters with the payload schemas' `Type`; nothing else infers them. Every write goes through the record, so it keeps the record's `transact`, writer re-read, after hooks and cache write-through. effect-jsonapi is given no `cache` or `transact`, so it neither writes the cache nor opens a second boundary, and its mutation tail re-reads the row once more through `get` to assemble the document (one extra read per write). An `update` whose payload `id` isn't the route's fails effect-jsonapi's `Conflict` before any operation runs. A `create` or `update` whose committed write hides the row from its writer answers effect-jsonapi's committed identifier document, `{ data: { type, id, meta: { version } } }` at the endpoint's own status (`201` / `200`), not a 404 that would invite a duplicating retry (effect-jsonapi's `hiddenWrite`, keyed on `NodeNotFound`; pulpit#107), so declare the group with `Group.flat({ …, hiddenWrites: true })`: effect-jsonapi's `Registry.FamilyFor` rejects these handlers for a group that doesn't. `restore` and `restoreVersion` stay on the record. The handlers fail with the record's tagged errors; mapping them onto the wire is the application's `ServerErrors`. The viewer and read-scope seams stay pulpit's until effect-auth publishes; delegating `LeafAccess` to it stays open on pulpit#5.
1084
+
1085
+ `update`, `delete` and `restoreVersion` take an optional `expectedVersion`. A client learns the head version from the resource it read: `meta.version`, or the single-resource response's `ETag` (`versionEtag`; see [`./subtype`](#subtype) → The head version on the wire). Mapping a request's `If-Match` (the tag's value) onto `expectedVersion`, and a `StaleVersion` failure onto `412 Precondition Failed`, is the HTTP layer's job, and so effect-jsonapi's: 0.17.0 has no such mapping, and setting the `ETag` header is the application's until it has.
1086
+
1087
+ ### `DocumentStore<Document, E, R>`: the content-addressed document store
1088
+
1089
+ Filled by the application, over its own document tables (the apps' `inline_documents` / `block_documents`; pulpit#53). No library is expected to provide one. Expected schema: a table per document family holding the document as `jsonb`, keyed by its hash, which the database generates:
1090
+
1091
+ ```sql
1092
+ hash_id bytea PRIMARY KEY GENERATED ALWAYS AS (digest(document_jsonb::text, 'sha256')) STORED,
1093
+ document_jsonb jsonb NOT NULL
1094
+ ```
1095
+
1096
+ **The hash contract.** The hash is SHA-256 (pgcrypto's `digest(…, 'sha256')`) over the UTF-8 bytes of the document's canonical text. The canonical text is PostgreSQL's `jsonb` text output (`document_jsonb::text`, in a `UTF8` database):
1097
+
1098
+ - object keys are deduplicated (the last wins) and ordered shorter-first by UTF-8 byte length, then bytewise;
1099
+ - `": "` follows each key and `", "` separates members and elements, with no other whitespace;
1100
+ - a string escapes `"`, `\` and the control characters below U+0020 (as `\b`, `\f`, `\n`, `\r`, `\t`, otherwise `\u00xx` in lowercase hex), and nothing else;
1101
+ - a number is written as `numeric` prints it, never with an exponent (`1e21` is `1000000000000000000000`, `1e-7` is `0.0000001`), and it keeps the scale it was written with: `{"n":1.0}` is `{"n": 1.0}` and `{"n":1}` is `{"n": 1}`, two different hashes although `jsonb` `=` calls the documents equal.
1102
+
1103
+ So structurally equal documents share a hash whatever their member order, provided their numbers are spelled alike; that holds for documents serialised as `JSON.stringify` does (shortest round-trip digits, so `1.0` is written `1`). The hash is exchanged as a `DocumentHash`, the 32 bytes as 64 lowercase hex characters (`encode(hash_id, 'hex')`). A store that does not run on Postgres must reproduce these bytes exactly: `canonicalText(json)` in [`./documents`](#documents) produces the canonical text (pinned, with its hashes, to Postgres), and the store hashes its UTF-8 bytes. `makeInMemoryDocumentStore` in [`./testing`](#testing) is the reference store, for tests only.
1104
+
1105
+ Method contract: `put` is `INSERT … ON CONFLICT DO NOTHING RETURNING hash_id`, and when the insert returns no row it reads the row that conflicted, `hash_id = digest($document::jsonb::text, 'sha256')`; it never updates a stored row (`DOC-001`). It returns `{ hash, inserted }`, `inserted` being whether the `RETURNING` gave a row back, so `onDocumentInsert` can tell a first insert from a no-op. `get` is `hash_id = $hash`; `getMany` is one `hash_id = ANY($hashes)` read, returning the documents found keyed by hash. `E` and `R` are as for the chain store, and the store never opens a transaction itself. A first insert's side effects (both apps enqueue an embedding job) go through `onDocumentInsert` (pulpit#45).
1106
+
1107
+ **The apps' column differs.** Both apps generate the hash as `digest(document_jsonb::text::bytea, 'sha256')`. The `::bytea` cast parses the text as `bytea` escape-format input, so it agrees with the contract only on a document whose canonical text has no backslash. A document with a `"`, a newline, a tab or another escaped character in a string or key fails to insert (`invalid input syntax for type bytea`), and one with a literal backslash hashes different bytes (pulpit#71 tracks the apps' fix). Gardeneria's no-row lookup, `document_jsonb = $document`, also differs from the contract's: `jsonb` `=` ignores a number's scale, so it can match a row stored at another hash. The contract's spelling, `digest(document_jsonb::text, 'sha256')`, uses pgcrypto's `text` overload, which is immutable and therefore valid in a generated column; `convert_to(…, 'UTF8')` gives the same bytes but is only stable, so a generated column can't use it.
1108
+
1109
+ ### `EdgeStore<Row, Following, E, R>`: the edge table
1110
+
1111
+ Filled by the application over each edge family's own table (pulpit#52); no library is expected to provide one. Expected schema: `id` (a UUID primary key), `created_at` (`timestamptz NOT NULL DEFAULT now()`), one column per end holding the node id (a foreign key to the node identity table, `core.node`) with an index each, the family's qualifier columns, and, for a family that resolves re-assertions, a unique index on its natural key (`NULLS NOT DISTINCT` when a key column is nullable).
1112
+
1113
+ Method contract:
1114
+
1115
+ - `get` is `id = $id LIMIT 1`.
1116
+ - `list` INNER JOINs each end's current-version view on `view.id = edge.<end column>`, under the end's alias, so an edge whose end has no view row is never listed. It renders `where` with the `ListFilter` renderer: a column `alias.name` resolves to that alias's view column, and a bare column to the edge table's. It orders by `created_at, id` and applies `LIMIT` / `OFFSET`. It returns the unpaged total as a window column (`count(*) OVER ()`), so the total is exact whenever the page has a row. An empty page reports `0`, including a page past the end of a non-empty list. That is gardeneria's documented rule, and the in-memory fake (`makeInMemoryEdgeStore` in [`./testing`](#testing)) follows it.
1117
+ - `countAt({ column, nodeId, where })` is `count(*)` over the same INNER JOINs as `list`, where `column = $nodeId` and `where` holds (rendered as `list` renders it). The create passes the viewer's `EDGE-001` filter and every end's liveness (`action` is `create` or `update`), so only edges the caller can read with every end live are counted ([#77](https://github.com/thomasfosterau/pulpit/issues/77), [#93](https://github.com/thomasfosterau/pulpit/issues/93)). On a family that declares `validity`, `where` also holds the overlap with the new edge's interval ([#210](https://github.com/thomasfosterau/pulpit/issues/210)). The create calls it inside its transaction for the cardinality check, once for each bounded end. The store locks the node first, so two creates at one node serialise and can't both take the last place. Three rules make that lock work:
1118
+ - **Lock with `FOR NO KEY UPDATE`:** `SELECT 1 FROM core.node WHERE id = $nodeId FOR NO KEY UPDATE`. Don't use `FOR UPDATE`. Every insert that references the node by foreign key (an edge in any family, or any other row keyed to `core.node`) takes `FOR KEY SHARE` on it, and `FOR UPDATE` would block those inserts for the rest of the transaction. `FOR NO KEY UPDATE` still conflicts with itself, so the creates still serialise.
1119
+ - **Run under READ COMMITTED** (PostgreSQL's default). There, the count after the lock wait sees the edge that the previous lock holder committed. Under REPEATABLE READ, the count reads the snapshot taken before the wait, so two creates can both see room and the node can overshoot its limit.
1120
+ - **Lock in ascending node id.** `create` calls `countAt` in ascending `nodeId` order, not declaration order. A self-referential family with two bounded ends (`a → b` and `b → a`) therefore locks its pair in one order, and two such creates can't deadlock. A store that locks anything else in `countAt` must keep to that order.
1121
+ - `existing(values)` is the id of the stored edge whose unique-key columns match `values` (`IS NOT DISTINCT FROM`), `LIMIT 1`; always none for a family without a unique key.
1122
+ - `insert(values)` is `INSERT … ON CONFLICT DO NOTHING RETURNING id`: the new id, or none when the key was taken (a racing writer, which the create then resolves through `existing`).
1123
+ - `delete(id)` is `DELETE … WHERE id = $id RETURNING id`: whether a row went.
1124
+ - `closeValidity(id, column, to)`, required only by a family that declares `validity`, is `UPDATE … SET <column> = $to WHERE id = $id RETURNING id`: whether a row was updated. It is the one statement that updates an edge, and it sets the interval's end column alone (`EDGE-002`); `closeValidity` checks the narrowing before calling it.
1125
+ - `members({ column, otherColumn, ids, where })` returns, as the `Following` the list filter's `in` node takes, the subquery `SELECT e.<column> FROM <edge> e` over the same INNER JOINs as `list`, `WHERE e.<otherColumn> = ANY($ids) AND <where>` (rendered as `list` renders it). In SQL it builds the subquery and reads nothing (a Drizzle `SQLWrapper`, the same type the follower seam's `following` returns, so the edge store and the leaf store share one `Following`). The in-memory fake computes the id set through its own list path. `memberFilter` and `nonMemberFilter` pass the viewer's `EDGE-001` filter and the named ids, and never call it with no ids or more than `MAX_SET_VALUES`. The subquery never yields a `NULL` (an edge end column is `NOT NULL`), which `nonMemberFilter`'s `NOT IN` relies on.
1126
+ - `countBy({ column, ids, where })` returns a `HashMap` from node id to count: `SELECT e.<column>, count(*) FROM <edge> e` over the same INNER JOINs as `list`, `WHERE e.<column> = ANY($ids) AND <where> GROUP BY e.<column>`, one statement for every id. A node with no matching edge has no key (`countBy` fills in `0`). `countBy` passes the viewer's `EDGE-001` filter, and never calls it with no ids, a duplicate id or more than `MAX_SET_VALUES`.
1127
+
1128
+ `E` and `R` are as for the chain store, and the store never opens a transaction itself. Each end's `EndpointView` is the endpoint leaf's own `LeafStore` (its `columns` and `get`).
1129
+
1130
+ ### `ProposalStore<E, R>`: the proposal table
1131
+
1132
+ Filled by the application over its own proposal table (gardeneria's `participation.proposal`); no library is expected to provide one. Expected schema: `id` (a UUID primary key), `target_node_id` (a foreign key to `core.node`), `author_id`, `patch` (`jsonb`, as submitted), `locales` (`jsonb`, nullable: a localised leaf's per-locale patch as submitted, pulpit#184), `rationale` (nullable), `base_version` (`bigint`), `state` (`pending | accepted | rejected | withdrawn`), `submitted_at` (`timestamptz NOT NULL DEFAULT now()`), and the nullable `decided_at`, `decided_by`, `decision_note` and `applied_version` (with `(target_node_id, applied_version)` a foreign key to the root version table). Index `author_id`, `target_node_id`, and the pending rows.
1133
+
1134
+ Method contract: `insert` inserts the row; `get(id)` is `id = $id LIMIT 1`, joined to `core.node` for `targetType`, with the target's head version as a correlated scalar subquery (`SELECT MAX(version) … WHERE id = target_node_id`: one indexed lookup per row, never a per-render aggregate over every node's versions); `lock(id)` is the row alone, `FOR UPDATE`, so two decisions on one proposal serialise; `close(id, decision)` updates the pending row's status, decider, timestamp, note and applied version; `list` renders `where` over the columns `authorId`, `targetId`, `targetType`, `status` and `targetOwnerId` (the target's owner, `core.node_owner`, `NULL` for none; a `LEFT JOIN`), orders by `submitted_at DESC, id DESC`, applies `LIMIT` / `OFFSET` and returns the unpaged total as a window column, each row with its target's head version as `get` reads it. `E` and `R` are as for the chain store, and the store never opens a transaction itself.
1135
+
1136
+ ### `BlobStore<E, R>`: the byte store
1137
+
1138
+ Filled by the application over its object storage (R2 in gardeneria, S3 or a local directory elsewhere); no library is expected to provide one. Keys are `blobKey(contentHash)` (`sha256/<hash>`), never a node id.
1139
+
1140
+ Method contract:
1141
+
1142
+ - `put(key, bytes, head)` writes the object, with `head.mediaType` as its content type. It is idempotent: a key always holds the same bytes.
1143
+ - `get` returns the bytes or none.
1144
+ - `head` returns `{ byteSize, mediaType }` or none, without reading the body.
1145
+ - `delete` removes the object; only purge calls it.
1146
+ - `publicUrl(key)` is the URL a reader fetches, when the store serves one.
1147
+ - `signedUploadUrl`, when the store offers one, signs a direct client upload of `key`'s bytes. The node stays `bytesPending` until `confirmBytes` sees them.
1148
+
1149
+ `E` is the store's own failure, and `R` whatever its client needs.
1150
+
1151
+ ### `PathStore<E, R>`: the path table
1152
+
1153
+ Filled by the application, over a path table per routable namespace (gardeneria's `pages` canonical paths plus `page_slugs`; pulpit#57): `(path PRIMARY KEY, node_id, canonical boolean)`. At most one canonical row per node is held as a partial unique index on `node_id WHERE canonical`. It runs in the caller's transaction and applies no policy. `lookup(path)` is the row by key. `canonicalOf(id)` is the node's canonical row. `claim(id, path)` is one statement or one transaction step: it fails `PathTaken` when another node's canonical row holds `path`. Otherwise it turns the node's canonical row, if any, into an alias, and upserts `path` as the node's canonical row, replacing an alias row at it, whoever's. `release(id)` deletes the node's canonical row and keeps its aliases. `makeInMemoryPathStore` in [`./testing`](#testing) follows this contract.
1154
+
1155
+ ### `ReportStore<E, R>`: the member report table
1156
+
1157
+ Filled by the application over its own report table (gardeneria's `user_reports`); no library is expected to provide one. Expected schema: `id` (a UUID primary key), `target_node_id` (a foreign key to `core.node`), `reporter_id`, `reason`, `details` (nullable), `status` (`open | resolved | dismissed`), `filed_at` (`timestamptz NOT NULL DEFAULT now()`), and the nullable `resolved_at`, `resolved_by` and `resolution_note`. Index `reporter_id`, `target_node_id`, and the open rows.
1158
+
1159
+ Method contract: `insert` inserts the row; `get(id)` is `id = $id LIMIT 1`, joined to `core.node` for `targetType`; `lock(id)` is the same row `FOR UPDATE`, so two resolutions serialise; `close(id, resolution)` updates the open row's status, resolver, timestamp and note; `list` renders `where` over `reporterId`, `targetId`, `targetType` and `status`, orders by `filed_at DESC, id DESC`, applies `LIMIT` / `OFFSET` and returns the unpaged total as a window column. `E` and `R` are as for the chain store, and the store never opens a transaction itself.
1160
+
1161
+ ### `BudgetCounter<E, R>` and `WriteCountStore<E, R>`: the write budget counter
1162
+
1163
+ Filled by the application for each family it throttles (pulpit#199); no library is expected to provide one. `take(request)` spends one write of `request.action` by `request.accountId` on `request.type` and answers whether it was within `request.limit` writes since `request.since`. It runs in the caller's transaction, so a write that rolls back spends nothing a transactional counter recorded. Two shapes fill it:
1164
+
1165
+ - **Counting** (`countingCounter(store)`, the default shape): `WriteCountStore.countSince({ type, action, accountId, since })` is `SELECT count(*) FROM <the family's table> WHERE <author column> = $accountId AND <created column> >= $since`, over an index on `(author column, created column)`. The write's own row is the spend, so `take` records nothing. Exact, but two concurrent transactions can both see room.
1166
+ - **Atomic**: a counter with its own state (a Durable Object, a counter row taken `FOR UPDATE`) checks and records the spend in one step, and records nothing when it answers `false`.
1167
+
1168
+ `makeInMemoryBudgetCounter` and `makeInMemoryWriteCountStore` in [`./testing`](#testing) follow this contract.
1169
+
1170
+ ### `PublicationStore<E, R>` and `LiveView<Row, Following, E, R>`: the live pointer
1171
+
1172
+ Filled by the application for a publish-gated leaf (pulpit#46).
1173
+
1174
+ The publication log is an append-only table: `node_id`, `version` (`bigint`, `NULL` for an unpublish, with `(node_id, version)` a foreign key to the root version table), `at`, `actor_id`, `recorded_at`, plus a serial `seq` for record order. Its methods:
1175
+
1176
+ - `record` inserts a fact.
1177
+ - `effective(id)` is the most recently recorded fact whose `at` has arrived: `SELECT … WHERE node_id = $id AND at <= now() ORDER BY seq DESC LIMIT 1`.
1178
+ - `facts(id, limit)` is `ORDER BY seq LIMIT $limit`.
1179
+
1180
+ The live view is a view over the leaf's version tables. For each node it gives the row at the effective fact's version (joined through the same `DISTINCT ON (node_id) … ORDER BY node_id, seq DESC` over the facts with `at <= now()`), with:
1181
+
1182
+ - `version` = the effective fact's version, the representation it serves, so its `meta.version` and ETag change only when what readers see does and never disclose an unpublished edit;
1183
+ - the node's head `action`, so a tombstone hides it too;
1184
+ - `draft` = `false`;
1185
+ - `publishedAt` = the fact's `at`;
1186
+ - the owner column.
1187
+
1188
+ A node whose effective fact is an unpublish, or that has none, has no live row.
1189
+
1190
+ `get` / `getMany` read it by id. `list` is `UNION ALL` of the current-version view's rows matching `headFor` (the viewer's own rows; `never` for the anonymous) and the live rows of every other node, then `where`, the order and the page, as `LeafStore.list` renders them.
1191
+
1192
+ `distinct` (pulpit#186) reads the distinct values of one column over that same `UNION ALL` of chosen rows, as `LeafStore.distinct` reads the current-version view, so a reader counts a node through the row they would list.
1193
+
1194
+ On a gated leaf the current-version view computes `draft` as `NOT EXISTS (a live row)` too, and a stored draft column is ignored.
1195
+
1196
+ **Publication serialises per node** (owner decision on pulpit#164). A `publish` or `unpublish` appends a fact and no version, so its cache write-through and eviction carry the node's head. Two publication operations on one head therefore compare equal in the cache's compare-on-set, and their order there isn't protected the way two chain writes' is. A publish whose write-through arrives late can re-cache a resource that an unpublish on the same head has already evicted (`CACHE-AUTH-005`). The application closes that gap by running the publication operations on one node one at a time.
1197
+
1198
+ - **The lock spans the whole handler call.** It covers the transaction and the cache refresh `subtypeHandlers` runs after it, which reads the live resource and writes it through.
1199
+ - **A row lock alone is not enough.** A row lock that `transact` takes on the node (`SELECT 1 FROM core.node WHERE id = $id FOR NO KEY UPDATE`) serialises the facts. It is released at commit, before the refresh, so it does not cover the write-through.
1200
+ - **Hold a lock across the call.** A transaction-independent lock does: a session-level advisory lock (`pg_advisory_lock(hashtext($id))`, released after the refresh), a Durable Object per node, or an in-process mutex keyed by node id where one process serves every write.
1201
+
1202
+ A pair stamp (`(head version, publication seq)`, compared lexicographically) would remove the need. It is the fallback if a store turns out unable to lock, and isn't in the seam today. A read-through fill on a gated leaf stamps the live row's `version`, which is below the head once the node has an unpublished edit, so the cache refuses it: a gated leaf's cache is filled by its write-throughs, which is the safe side.
1203
+
1204
+ ### `WorkingCopyStore<E, R>`: the autosave buffer
1205
+
1206
+ Filled by the application: a table keyed `(node_id, user_id)` holding the patch (`jsonb`), `base_version` and `saved_at`, or a KV namespace. Methods:
1207
+
1208
+ - `put` upserts the copy;
1209
+ - `get` reads it;
1210
+ - `discard` deletes it, idempotently.
1211
+
1212
+ It is the one overwritable store in the kernel's seams, and nothing in it is ever a version.
1213
+
1214
+ ### `PurgeStore<E, R>`: erasure
1215
+
1216
+ Filled by the application, over the same tables as its other stores. Like them it applies no policy, runs in the caller's transaction and never opens one. `definePurge` calls its three methods in order:
1217
+
1218
+ - `eraseNode(id)` deletes every row of the node: each chain level's version rows, the root version rows and the identity row (`core.node`), the owner row, and every row keyed to the node in the other seams' tables (working copies, proposals, publication facts, path claims and aliases, reports, and edges with the node at either end). An `ON DELETE CASCADE` from the identity row does most of it. It returns the `DocumentHash`es and `ContentHash`es the deleted version rows carried, each once (`DELETE … RETURNING` over the columns that carry them). An id with no rows deletes nothing and returns none.
1219
+ - `deleteUnreferencedDocuments(hashes)` deletes each of `hashes` that no remaining version row of any leaf references, and returns those it deleted: `DELETE FROM documents WHERE hash = ANY($1) AND NOT EXISTS (…)` over every column that carries a document reference.
1220
+ - `unreferencedBlobs(hashes)` returns those of `hashes` that no remaining version row references, for `definePurge` to delete from the `BlobStore` once the transaction has returned.
1221
+
1222
+ Erasure is the only path that deletes a version row, a document or bytes (`VERSION-CHAIN-001/002`, `DOC-001`): nothing else may call these methods' SQL.
1223
+
1224
+ ### `SearchBranch<Following, E, R>` and `EmbeddingStore<E, R>`: search and embeddings
1225
+
1226
+ Filled by the application over its own search views and embedding tables (pulpit#204); no library is expected to provide one.
1227
+
1228
+ - A `SearchBranch`'s `search({ query, scope, limit })` is one ranked query over the leaf's search view, returning at most `limit` ids, best first. It must render `scope` (the leaf's `searchScopeFilter`) into the same statement that ranks, as the leaf's `list` renders its filter, and on a publish-gated leaf match over the rows `LiveView.list` reads; a branch that ranks first and filters after leaks a hidden row's rank into the others'. A full-text branch is `ts_rank_cd` over the projection's `setweight` vector, a trigram one `similarity`, a vector one `ORDER BY embedding <=> $query` with a `LIMIT`, a literal-id branch an exact match (ISBN, DOI, QID). It applies no other policy and never opens a transaction.
1229
+ - An `EmbeddingStore` is the queue and the vector column. `enqueue(hash)` inserts an outbox row (`ON CONFLICT DO NOTHING`); `pending(limit)` is the oldest `limit` queued hashes and, for the sweep, any stored document whose vector is still `NULL` (`ORDER BY queued_at LIMIT $limit`); `write(vectors)` sets each document's vector and deletes its queue row in one statement; `discard(hashes)` deletes queue rows. An `Embedder` is the model: one call per batch, one vector per text, in order.
1230
+
1231
+ ### `ReactionStore<E, R>`: the reaction table
1232
+
1233
+ Filled by the application over its own reaction table (gardeneria's `social.reactions`): `node_id` (a foreign key to `core.node`, `ON DELETE CASCADE`), `account_id` (`ON DELETE CASCADE`), `kind` (`text`), `reacted_at` (`timestamptz NOT NULL DEFAULT now()`), with primary key `(node_id, account_id)` and an index on `(account_id, reacted_at DESC)`. `upsert` is `INSERT … ON CONFLICT (node_id, account_id) DO UPDATE SET kind = EXCLUDED.kind, reacted_at = EXCLUDED.reacted_at` returning the replaced kind (read in the same statement through a CTE); `delete` is `DELETE … RETURNING`; `tally(nodeIds)` is one `SELECT node_id, kind, count(*) … WHERE node_id = ANY($ids) GROUP BY node_id, kind`; `byAccount` is `WHERE account_id = $id ORDER BY reacted_at DESC, node_id LIMIT … OFFSET …`; `eraseAccount` is `DELETE … WHERE account_id = $id`. It applies no policy and never opens a transaction.
1234
+
1235
+ ### `VersionedKv<E, R>`: the cache's key-value store
1236
+
1237
+ Filled by the application, for `./cache`'s `makeResourceCache` (pulpit#205). Each entry is `{ version, value }`, where `value` is the resource's JSON encoding or none for an eviction, and `version` is the rank the cache orders by (`2·version`, plus one for an eviction); the store compares it as a plain number.
1238
+
1239
+ - `get(key)` returns the entry, or none when there is no entry or it has expired.
1240
+ - `putIfNotBehind(key, entry, ttlSeconds)` writes the entry, with that TTL, unless the stored entry's version is higher. It returns whether it wrote. The compare and the write must be **one atomic step**:
1241
+ - a Durable Object's single-threaded storage;
1242
+ - Redis, as a Lua script, or `WATCH` / `MULTI`;
1243
+ - SQL, as `INSERT … ON CONFLICT (key) DO UPDATE … WHERE cache.version <= EXCLUDED.version`.
1244
+
1245
+ Workers KV alone has no conditional write, so it can't fill this seam. The store applies no policy.
1246
+
1247
+ ### `CollectionStore<E, R>`: the membership table
1248
+
1249
+ Filled by the application over its own membership table:
1250
+
1251
+ - **Columns:** `parent_id` and `member_id` (foreign keys to `core.node`, `ON DELETE CASCADE`) and `position` (`integer`), with primary key `(parent_id, member_id)` and an index on `(parent_id, position)`.
1252
+ - **`entries(parentId, limit)`** is `ORDER BY position, member_id LIMIT $limit`.
1253
+ - **`replace(parentId, entries)`** rewrites the parent's order in one statement: a `DELETE` of the parent's rows and an `INSERT` of the new ones, in the caller's transaction.
1254
+
1255
+ It applies no policy and never opens a transaction. A versioned family's parent column is a `jsonb` array of member ids on the parent's version table.
1256
+
1257
+ ### `SocialStore<Following, E, R>`: the relationship table
1258
+
1259
+ Filled by the application over its own relationship table (gardeneria's `social.follows`, widened by a `kind`): `kind` (`follow | mute | block`), `from_id` and `to_id` (account foreign keys, `ON DELETE CASCADE`), `at` (`timestamptz NOT NULL DEFAULT now()`), primary key `(kind, from_id, to_id)`, with indexes on `(kind, to_id, at DESC)` and `(kind, from_id, at DESC)`. `add` (a mute) is `INSERT … ON CONFLICT DO NOTHING RETURNING`; `addFollow` and `addBlock` first take one lock on the unordered account pair in the caller's transaction (`pg_advisory_xact_lock` on it), so the two are atomic against each other: `addFollow` is `INSERT … SELECT … WHERE NOT EXISTS (a block either way) ON CONFLICT DO NOTHING`, answering `blocked` when a block stands, and `addBlock` inserts the block and deletes both follows; `remove` is `DELETE … RETURNING`; `has` is an `EXISTS`; `list` is one page by `from_id` or `to_id`, `ORDER BY at DESC`, then the other account's id, with the window total; `isFollowing` is the follower seam's `EXISTS` (a failure reads as `false`, failing closed); `followingOf(follower)` is the subquery `SELECT to_id … WHERE kind = 'follow' AND from_id = $follower`, and `silencedBy(viewer)` the same over `kind IN ('mute', 'block')`, both never materialised; `eraseAccount` deletes every row from or to the account. It applies no policy and never opens a transaction.
1260
+
1261
+ ### `ResourceCacheSeam` and the `serveable` hook: the resource cache
1262
+
1263
+ Filled by the application: a read-through resource cache it owns (pulpit#5, pulpit#6). No library is expected to provide one; effect-web dropped its Cloudflare package, and with it the planned cache (effect-web#33). The cache's write side is `set(id, resource, version)` / `delete(id, version)`, both never failing (the cache swallows a store fault).
1264
+
1265
+ **Compare-on-set** (pulpit#121). Each call carries the node version it speaks for: the version the write landed at for the handlers' write-through and eviction, and the head version its read saw for a read-through fill, which goes through `set` too. The cache keeps, per id, the highest version either call has given it. A `set` below that version is a no-op; a `set` at or above it stores the resource (or, when not serveable, evicts it and keeps the version); a `delete` evicts and raises the version. The kept version must outlive the entry it evicted, at least as long as an entry lives. That way neither a racing writer's late write-through nor a fill that read before a hiding write can re-cache a resource older than one already evicted or replaced (`CACHE-AUTH-005`). The compare and the write are one atomic step: a conditional write (a SQL `INSERT … ON CONFLICT … DO UPDATE … WHERE cached.version <= excluded.version`, a Durable Object, a compare-and-swap). A store without one, such as Workers KV, which is eventually consistent and has no conditional put, cannot honour this seam on its own. `makeInMemoryResourceCache` in [`./testing`](#testing) is the reference.
1266
+
1267
+ **Only committed writes are cached** (pulpit#124). `transact` is always-open, so inside an outer transaction it is a SAVEPOINT and its return is not a commit. `SubtypeHandlersConfig.inTransaction` says which: over `effect/sql`, `Effect.map(Effect.serviceOption(client.transactionService), Option.isSome)`, read once `transact` has returned. When it reads true, the handlers evict at the write's version instead of writing through, so an outer rollback leaves no cached entry; the eviction raises the cache's version, so a read-through fill below it is refused until the next write or a fill at that version, which is the safe side. Once `transact` offers an after-commit hook, the write-through moves onto it.
1268
+
1269
+ Its security policy is `serveablePolicy({ owned, publishGated })` from `./publication`, passed as the cache's `serveable` option; the cache applies it on both its read and write paths and evicts on a non-serveable write.
1270
+
1271
+ ## License
1272
+
1273
+ MIT