@raybase/record-mount 0.0.1 → 0.3.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Itself & Co
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,433 @@
1
- # @raybase/record-mount
1
+ # `@raybase/record-mount`
2
2
 
3
- This name is reserved for Raybase. Nothing is released under it yet, and this package holds no code.
3
+ Optional, explicitly opt-in Raybase module for mounting an **external
4
+ append-only record layer** into a Raybase app.
5
+
6
+ The record layer is a separate service — a Cloudflare Worker in front of an
7
+ append-only store — that the app reaches over a service binding. This package
8
+ is the app's half of that seam: a typed client pinned to the service's wire
9
+ contract, the two authorization gates that belong in front of it, and the body
10
+ composer a filing needs. It is a **wire client**, not a database: it stores
11
+ nothing, owns no schema, and knows no family names.
12
+
13
+ ## Why this is in Core and not Pro
14
+
15
+ Recorded at adoption, as a placement decision should be.
16
+
17
+ **Core.** The mount is client machinery over a published wire contract. It
18
+ touches nothing Pro owns — no D1 control plane, no organizations or
19
+ memberships, no SSO, no Hub, no licensing — and it needs nothing from Pro to
20
+ work. It sits exactly where `@raybase/api` and `@raybase/openai-subscription`
21
+ sit: an optional first-party module a Core app adds explicitly. Putting it in
22
+ Pro would deny it to Core apps and buy nothing.
23
+
24
+ Placement in Core prices this capability at zero, permanently, and that is
25
+ accepted deliberately. It also draws the seam in the right place for later:
26
+ **Core owns the read-policy hook; an organization-aware implementation of that
27
+ hook is legitimate Pro work.** The interface is the open half; the
28
+ sophisticated filler can be the commercial half without moving anything.
29
+
30
+ ## Provenance
31
+
32
+ This package folds **two independent builds** of the same brief, written the
33
+ same day against the same request.
34
+
35
+ An HQ-application session, working ahead toward this package as a dependency,
36
+ built the contract declarations, the error class and envelope rehydration, the
37
+ client with its construction-time address checks and per-stub contract memo,
38
+ the body composer, the mount's two-gate design, and the first draft of the
39
+ in-memory door double. A provider-side session built the read-policy semantics,
40
+ reviewed and finished the above, and wrote the packaging, the suite and this
41
+ file.
42
+
43
+ Where the two designs differed, the merits decided and the reasons are in the
44
+ code. Named plainly because a package's history is part of what a reader is
45
+ owed, and because "who wrote this" is not a question a changelog should have to
46
+ guess at later.
47
+
48
+ ## The shape
49
+
50
+ ```ts
51
+ import {
52
+ createRecordBodyComposer,
53
+ defineRecordMount,
54
+ openRecordDoor
55
+ } from "@raybase/record-mount";
56
+ import { allowAll } from "@raybase/core";
57
+ import type { RaybaseEnv } from "@raybase/core/worker";
58
+ import type { RecordDoorStub } from "@raybase/record-mount";
59
+
60
+ type Env = RaybaseEnv & { RECORDS: RecordDoorStub };
61
+ ```
62
+
63
+ The binding is an ordinary Cloudflare service binding in the app's own
64
+ `wrangler.jsonc`. Raybase adds no obstacle here: `RaybaseEnv` is an
65
+ intersection type and unknown bindings ride the app's config untouched.
66
+
67
+ ```jsonc
68
+ {
69
+ "services": [
70
+ {
71
+ "binding": "RECORDS",
72
+ "service": "your-record-door",
73
+ "entrypoint": "WriteDoor",
74
+ "props": { "actor": "your-app", "namespace": "prod" }
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ Then, per request:
81
+
82
+ ```ts
83
+ const door = openRecordDoor({ env, binding: "RECORDS", namespace: "prod" });
84
+
85
+ const mount = defineRecordMount({
86
+ name: "records",
87
+ door,
88
+ auth: { audience: "signed-in", policy: allowAll },
89
+ readPolicy: ({ envelope, viewer }) => visibleTo(envelope, viewer),
90
+ unfilteredReads: ["materialize"],
91
+ composer
92
+ });
93
+
94
+ const page = await mount.list({ family: "decision", status: ["proposed"] }, viewer);
95
+ ```
96
+
97
+ ## The address is deploy configuration, not an argument
98
+
99
+ No verb takes an actor or a namespace. Both ride the binding's `props`, which
100
+ only the app's deploy config can write and which the calling code has no
101
+ runtime channel to set or forge. That one-wayness is what makes the address
102
+ trustworthy, and it is why `namespace` on `openRecordDoor` is documented as a
103
+ **declaration, not a verification**: it buys one namespace per mount, a legible
104
+ name in refusals, and a written claim a reviewer can diff against the deployed
105
+ config. It cannot catch a config that says one thing here and another in the
106
+ props, and it does not pretend to.
107
+
108
+ Which entrypoint the app binds decides what it may do. A read door genuinely
109
+ lacks `append` and `transition`, so the runtime refuses a write before any
110
+ policy code runs. That wall is real and it always held.
111
+
112
+ ### What a client can and cannot see across the seam
113
+
114
+ **A Cloudflare RPC service-binding stub answers every property access with a
115
+ callable.** `typeof stub.append === "function"` is true against a read door,
116
+ true against a service that is not a record door at all, and true for a
117
+ property nobody ever defined. Any check written on property presence is
118
+ reading its own assumption back.
119
+
120
+ So the client asks the door instead. `version()` carries an optional
121
+ `writes` field — the entrypoint's own declaration of whether it holds the write
122
+ verbs — and `door.writable` is a **getter with three states**:
123
+
124
+ | `door.writable` | Means |
125
+ |---|---|
126
+ | `true` | a write door: a plain binding really has the methods, or an RPC door declared `writes: true` |
127
+ | `false` | a read door: a plain binding really lacks them, or an RPC door declared `writes: false` |
128
+ | `"unknown"` | an RPC door whose declaration has not arrived yet, or one old enough not to declare at all |
129
+
130
+ `"unknown"` is an honest third state, not a placeholder. For a definite answer,
131
+ `await door.assertContract()` first and then read it: the contract check is the
132
+ call that fetches the declaration, and it is memoized per binding stub, so a
133
+ later client on the same isolate reads the answer without a second round trip.
134
+
135
+ `"unknown"` never blocks a write. The call goes out, and if the door turns out
136
+ to lack the verb, the runtime's method-missing `TypeError` is **translated**
137
+ into this package's typed `forbidden` refusal, with the original runtime
138
+ message in `detail.cause`. A consumer switches on the taxonomy either way and
139
+ never has to sniff a runtime error's wording.
140
+
141
+ The translation is deliberately narrow: it fires only when the `TypeError`'s
142
+ message carries the observed method-missing shape *naming the verb itself*
143
+ (`does not implement … "append"`). Two cases therefore propagate untouched: a
144
+ door that declared `writes: true` and throws anyway (lying or broken — dressing
145
+ a real defect as deploy configuration would bury it), and any `TypeError` that
146
+ is not this verb's own method-missing (a broken door, a serialization failure —
147
+ one such mislabel hid a real invocation bug for an evening,
148
+ fnd-2026-08-31-mount-write-call-invocation). If workerd's wording ever shifts,
149
+ the translation stops firing and the raw error surfaces: loud beats mislabeled.
150
+
151
+ For the same reason, the "this binding is not a record door" check at
152
+ `openRecordDoor` runs **only on a plain binding**, where absence is real. On an
153
+ RPC stub it would pass against anything, so it is skipped rather than performed
154
+ and believed; a binding pointing at the wrong service is caught at its first
155
+ call, as `invalid_binding`.
156
+
157
+ ## The contract pin is fail-closed
158
+
159
+ The client pins `RECORD_DOOR_CONTRACT_VERSION` and calls `version()` on its
160
+ first verb — memoized per binding stub, which is per isolate. A door serving a
161
+ different version refuses `contract_mismatch` and **nothing proceeds**. There is
162
+ no degrade path and no negotiation.
163
+
164
+ The check lands at the first verb rather than at construction because a Worker
165
+ constructs its handle on every request, and an RPC round trip there would price
166
+ the check per request. `assertContract()` is the eager form for an app with a
167
+ real boot hook. Two codes are terminal and stay memoized — `contract_mismatch`
168
+ (this door serves another shape) and `invalid_binding` (this binding points at a
169
+ service that is not a record door); both are deploy configuration, and neither
170
+ resolves by being asked again inside one isolate. A failure to *reach* the door
171
+ clears the memo, so a flap does not poison an isolate for its lifetime.
172
+
173
+ The memo holds the **reply**, not merely the verdict. A Worker builds a client
174
+ per request over one isolate-lifetime stub, so every client after the first
175
+ reads the door's declared capabilities out of the memo rather than never
176
+ learning them.
177
+
178
+ ### When the pin moves, and when it does not
179
+
180
+ It moves for any change that breaks either direction of the seam: a verb added
181
+ or removed, an argument or return shape altered, a taxonomy code appearing on a
182
+ verb that could not raise it.
183
+
184
+ It does **not** move for an additive optional field with defined absence
185
+ semantics, because both directions stay compatible — an old client ignores the
186
+ field, and a new client reads absence as "undeclared" and degrades toward
187
+ `unknown`. `version().writes` is exactly that, and it is why the pin is still
188
+ `"1"`. The alternative was worse than pedantic: the pin is fail-closed, so a
189
+ bump to `"2"` would have refused every currently deployed door at once, and this
190
+ package cannot execute that flag day — the doors are deployed by the consuming
191
+ organization, not from this repository. A version bump nobody can land is an
192
+ outage wearing a migration.
193
+
194
+ ## Two gates, and they answer different questions
195
+
196
+ **Gate 1 — the route's Raybase auth declaration.** `audience` and `policy`,
197
+ exactly as a Raybase action declares them, validated through Core's own
198
+ machinery at mount time and evaluated through it on every call.
199
+ `defineRecordMount` **refuses to mount without one**: a record layer is not a
200
+ surface on which "I forgot" may be spellable, so an explicitly public mount says
201
+ `{ audience: "public", policy: allowAll }` and means it.
202
+
203
+ Every principal kind is answered for, because the declared policy is evaluable
204
+ against only two of them:
205
+
206
+ | Principal | Gate 1 |
207
+ |---|---|
208
+ | `public` | judged by the audience alone |
209
+ | `user` | evaluated against the declared policy |
210
+ | `operator` | **also** evaluated against the declared policy — an operator is not exempt |
211
+ | `service` | **refused**, unless the mount declares `allowServicePrincipals` |
212
+ | `agent` | judged as the `user`, `public` or `service` principal it acts for |
213
+
214
+ A service principal carries no user, so a role policy against it is not merely
215
+ unevaluated but unevaluable — and "the policy could not be applied" must never
216
+ read as "the policy passed".
217
+
218
+ The same rule applies one step earlier, where it is knowable at mount time: a
219
+ mount whose audience admits `public` and whose policy is narrower than
220
+ `allowAll` is **refused at mount time**. That declaration is contradictory — it
221
+ admits anonymous callers, who carry no user for the restriction to be evaluated
222
+ against — and the alternative is for it to resolve silently in the permissive
223
+ direction. Narrow the audience, or say `allowAll` and mean it.
224
+
225
+ **Gate 2 — the read-policy hook.** Envelope in, allow or deny out, applied to
226
+ every row and every `get` the mount serves. The default is
227
+ `allowAnyAuthenticated`: any non-anonymous, non-disabled principal reads
228
+ everything, and an agent counts as the principal it acts for. What replaces it
229
+ is the consuming app's design. A hook receives the viewer as given, so an agent
230
+ run arrives as kind `agent`; read `principalOnBehalfOf(viewer.principal)` to
231
+ judge by whom it acts for.
232
+
233
+ A hook that **throws** is not a denial. It propagates: a policy that cannot
234
+ decide has not decided, and turning that into "deny" would render an outage as a
235
+ silently empty page.
236
+
237
+ ### Deny means absence
238
+
239
+ A denied `get` answers `not_found`. A denied row is simply not in its page. The
240
+ two are indistinguishable on purpose: a `forbidden` on a specific id confirms
241
+ that the id exists, which is the leak the policy was installed to close.
242
+
243
+ Indistinguishable means **byte-identical**, not merely same-code. The denial's
244
+ message is built from the same default a record door's own `not_found` uses
245
+ (`` `${code}: ${JSON.stringify(detail)}` ``) rather than written out here.
246
+ Matching on `detail` alone is not enough: a denial that said `not_found: <id>`
247
+ against a door whose absence says `not_found: {"id":"<id>"}` differs in the
248
+ half most likely to reach a log, a toast or an error page, and one probe per id
249
+ is an existence oracle again.
250
+
251
+ ### A filtered page is a shorter page
252
+
253
+ Filtering shrinks a page. A `limit: 50` list whose viewer may read twelve of
254
+ them returns twelve rows **and the page's own cursor**. The cursor is still
255
+ correct — it is the door's keyset position, not a count — so a consumer that
256
+ needs full pages loops until it has enough. This is stated rather than hidden
257
+ because a shrinking page is exactly what a row policy costs.
258
+
259
+ The hook is asked about a page at bounded concurrency (`readPolicyConcurrency`,
260
+ default 16), because a hook is expected to read app tables and a page may carry
261
+ a thousand rows. Order is preserved regardless of completion order.
262
+
263
+ ### `count` and `materialize` are fail-closed
264
+
265
+ Neither can be filtered by a per-envelope hook, for different reasons. `count`
266
+ is folded inside the door and returns totals, not records: filtering it would
267
+ mean counting client-side over the whole namespace, and serving it unfiltered
268
+ discloses the shape of a corpus the viewer may not read. `materialize` streams
269
+ stored bytes — whole files, several records to a file in some forms — so there
270
+ is no envelope for the hook to judge at all.
271
+
272
+ So both are gated, and **the gate arms on a pair, decided once at mount time:
273
+ can the effective read policy deny a viewer this mount's audience admits?**
274
+ Armed, they refuse `forbidden` unless the mount declares them:
275
+
276
+ ```ts
277
+ unfilteredReads: ["materialize"] // this mount folds whole pages, deliberately
278
+ ```
279
+
280
+ | Mount | Deniable admitted viewer? | Gate |
281
+ |---|---|---|
282
+ | `{signed-in, allowAnyAuthenticated}` (the default) | no — the audience admits only authenticated viewers, and the default allows all of them | **unarmed**, both verbs flow |
283
+ | `{public, allowAll}` with the default read policy | yes — public principals are admitted, and the default denies them | **armed** |
284
+ | `{["public","signed-in"], …}` with the default | yes — the public class is still admitted | **armed** |
285
+ | any policy this package cannot see inside | assumed yes | **armed** |
286
+ | `denyAllRecords` | yes, on every audience | **armed** |
287
+
288
+ Both halves of the pair matter. Arming only on "is there a custom policy?"
289
+ fails **open** — the default denies public principals, so a public-audience
290
+ mount served an anonymous caller the totals and the bytes while `list` served
291
+ that same caller nothing. Arming on "can this policy deny anybody?" fails
292
+ **noisy** — the default denies *some* principal, so every mount would arm and
293
+ `unfilteredReads` would become mandatory boilerplate instead of a signal.
294
+
295
+ A policy this package did not write is always armed, wrapper or not. That is
296
+ deliberate: being unrecognised is never the permissive answer, which is why
297
+ `readPolicy: (i) => allowAnyAuthenticated(i)` arms even though it behaves
298
+ exactly like the default. Erring toward a declaration is the safe direction.
299
+
300
+ `unguardedDoor` remains the general escape hatch for a caller that wants the
301
+ client with no gate at all and is willing to say so at the call site.
302
+
303
+ ### A paged `materialize` is not a snapshot
304
+
305
+ The cursor is a position in a deterministic order, not a point in time. A
306
+ multi-page walk taken while writes land under the same prefix can repeat an
307
+ entry or miss one. Walk a quiesced namespace when that matters.
308
+
309
+ ## The body composer
310
+
311
+ The door takes **bytes, not fields** — it parses a record's envelope out of the
312
+ body it is handed, which is what makes a record immutable and its digest
313
+ meaningful. So a filing surface needs a composer, and without one every form
314
+ field is a string concatenation.
315
+
316
+ This one is generic over consumer-supplied family descriptors. No family, no
317
+ field name and no id prefix is hardcoded anywhere in this package; the
318
+ consumer's schema domain stays the consumer's. What the package owns is the
319
+ **dialect** and the discipline around it:
320
+
321
+ - Field order is the descriptor's, so the same fields always compose to the same
322
+ bytes and therefore the same digest.
323
+ - Only declared fields may appear, and required ones must. An unknown input
324
+ field refuses rather than being dropped — a silently ignored field is a filing
325
+ that lies.
326
+ - Four render forms only — `scalar`, `list`, `folded`, `map` — because the
327
+ reader on the other end is a minimal one. A value that cannot be rendered
328
+ unambiguously in that subset is **refused**, never escaped: a value that needs
329
+ escaping to survive is a value the far reader will get wrong.
330
+ - **Composition self-checks.** Every composed body is parsed back with the
331
+ dialect's own reader and compared field by field before it is returned. The
332
+ canonical-bytes discipline is an assertion here, not a promise.
333
+ - **The reader accepts a superset of what the writer emits**, deliberately. A
334
+ mount reads records other writers filed, so `parseRecordFrontmatter` tracks
335
+ the record substrate's own reader rather than this composer: inside a
336
+ one-level map a sub-value may itself fold (` sub: >-`, or a bare ` sub:`,
337
+ followed by indented continuation lines rejoined with single spaces), and a
338
+ tab-led line counts as a continuation rather than as a new top-level key.
339
+ The composer emits neither form. Reading *less* than the substrate would
340
+ make the mount misread real records the substrate serves correctly — which
341
+ is a silent wrong answer, the one failure this package will not ship.
342
+
343
+ Refusals arrive as `RecordDoorError` with the door's own `invalid_record` code,
344
+ so a form handler has one error surface for "this filing is malformed" whether
345
+ the composer caught it or the door did — and neither becomes a 500.
346
+
347
+ ### Writing: `by` is provenance, and the door checks it against the actor
348
+
349
+ `mount.transition(...)` passes the caller's whole event through, `by` included.
350
+ A door may guard that field: ours matches `by` against the provenance prefixes
351
+ the *binding's actor* is allowed to stamp, and refuses `forbidden` when they
352
+ disagree. So an app that stamps `by` from the signed-in user, while its binding
353
+ carries some other actor, will meet a `forbidden` whose cause is not obvious
354
+ from the call site.
355
+
356
+ `by` is per-act provenance, never identity, and no consumer should build an
357
+ authorization on it. Stamp it with something the binding's actor is entitled to
358
+ claim, and keep the viewer's identity in the event's `fields` if the record
359
+ should carry it.
360
+
361
+ ## Testing
362
+
363
+ `@raybase/record-mount/testing` exports `createRecordDoorDouble()`, a
364
+ contract-conformant in-memory door: the result envelope, the closed taxonomy,
365
+ the Row projection, keyset paging, append idempotency by content, and the
366
+ ordering rule that `forbidden` outranks `not_found`. It is generic — family, id
367
+ shape, lifecycle vocabulary, grants and headline derivation are all hooks with
368
+ documented defaults — and it lets a consuming app exercise its mount with no
369
+ deployed Worker, no service binding and no network.
370
+
371
+ **It is not the door.** Bytes the double accepts are not thereby proven
372
+ acceptable to a deployed door: its frontmatter reader is this package's own, so
373
+ a suite green against it is a builder-authored oracle for wire acceptance and
374
+ nothing more. The real-door cross-check is a consumer-side act against a running
375
+ service, and the double does not stand in for it.
376
+
377
+ ### The double presents as an RPC stub by default
378
+
379
+ It used to hand back a plain object that simply omitted `append`/`transition`
380
+ for a read door, and that cost a real defect: the production seam answers every
381
+ property access with a callable, so every property-presence check read one way
382
+ against the fixture and the other way against a deployed door. The suite was
383
+ green on a shape that does not exist in production.
384
+
385
+ So `createRecordDoorDouble()` now returns a **Proxy that answers every property
386
+ with a function**, where calling a verb the door does not carry rejects with a
387
+ `TypeError` imitating the runtime's method-missing shape — and the manners go
388
+ one level down: a callable read off the stub answers *its* property accesses
389
+ (`call`, `apply`, anything) with further method-missing callables, as the real
390
+ proxy does, so an extracted-and-re-invoked verb fails in the fixture the same
391
+ way it fails in production. The message shape (`does not implement` naming the
392
+ method) is what the client's translation keys on; **do not pin the full
393
+ sentence in a test** — workerd's wording is not ours to guarantee, and when it
394
+ changes the client deliberately stops translating rather than guessing.
395
+
396
+ ```ts
397
+ createRecordDoorDouble({ writable: false }); // RPC read door, declares writes: false
398
+ createRecordDoorDouble({ writable: false, declaredWrites: "omit" }); // an older door: declares nothing
399
+ createRecordDoorDouble({ rpc: false }); // a plain object, where absence is absence
400
+ ```
401
+
402
+ Pass `rpc: false` when the thing you are standing in for really is an
403
+ in-process implementation. Everything else should keep the default: a fixture
404
+ kinder than the seam is a fixture that lies.
405
+
406
+ Two more specifics worth knowing before you build on it. Its **`materialize` path
407
+ layout (`family/id`) is invented** — a real door's tree is its substrate's, and
408
+ is not that — so a prefix convention tested against the double is testing the
409
+ double. And its **refusal messages are all one shape**, the error type's
410
+ default; a real door authors bespoke prose at some sites, so match on `code` and
411
+ `detail`, not on message text. The single exception is `not_found`, where the
412
+ shapes genuinely agree and the agreement is load-bearing for deny-means-absence
413
+ above.
414
+
415
+ ## Status
416
+
417
+ Version 0.1.0. This repository's own suite runs against the in-memory double —
418
+ a **Proxy in the test process** standing in for a Cloudflare service binding;
419
+ nothing here reaches workerd.
420
+
421
+ The real seam has now been walked by a consumer, though: as of 428c3a47
422
+ (2026-08-31) the first consumer's topology suite drives every read and write
423
+ verb of this client against a **deployed record door over a real service
424
+ binding** — including doors that declare `version().writes` on both entrypoints
425
+ — and runs green. That walkthrough is what surfaced (and now pins the fix for)
426
+ the two real-seam defects the double alone could not see: writability read off
427
+ stub property presence, and write verbs invoked via an extracted
428
+ `Function.prototype.call` that the RPC proxy answers as a method named "call".
429
+ The proof lives in the consumer's suite, not this one — this package's own
430
+ tests remain a simulation, and the double is kept faithful to what the real
431
+ seam has actually been observed to do. One deliberately open corner: whether a
432
+ real stub answers `toString`/`valueOf` as RPC methods, as the double does, has
433
+ not been observed; if the real seam differs, the double follows the seam.
package/dist/body.d.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The per-family body builder: filing from a form means composing a
3
+ * canonical record body, and this is the machinery that composes it.
4
+ *
5
+ * **It is generic over consumer-supplied family descriptors.** No family,
6
+ * no field name and no id prefix is hardcoded anywhere in this package —
7
+ * the consumer's schema domain stays the consumer's. What this module owns
8
+ * is the *dialect*: which frontmatter forms it will emit, deterministically,
9
+ * and which inputs it refuses rather than rendering ambiguously.
10
+ *
11
+ * ## The dialect, and why it is small
12
+ *
13
+ * A record body is frontmatter plus prose, and the parser on the other end
14
+ * is a minimal one — the record substrate parses the envelope out of the
15
+ * body itself, with a hand-written reader rather than a YAML library.
16
+ * Emitting the full YAML surface would produce bytes that round-trip
17
+ * through a real YAML parser and are silently misread by a minimal one, so
18
+ * this composer emits four forms only:
19
+ *
20
+ * - `scalar` — `key: value`
21
+ * - `list` — `key: [a, b]`
22
+ * - `folded` — `key: >-` with two-space continuation lines, rejoined with
23
+ * single spaces on read
24
+ * - `map` — a bare `key:` with two-space `sub: value` lines
25
+ *
26
+ * and it **refuses** any value it cannot render unambiguously in that
27
+ * subset. A refusal is loud and typed; there is no escaping fallback,
28
+ * because a value that needs escaping to survive is a value the reader on
29
+ * the other end will get wrong.
30
+ *
31
+ * That list is the WRITE dialect, and the reader in this module is wider
32
+ * than it: a mount reads records other writers filed, so the reader tracks
33
+ * the record substrate's own reader (folded map sub-values, tab-led
34
+ * continuations) rather than this writer. See `parseRecordFrontmatter` —
35
+ * the asymmetry is deliberate and documented there.
36
+ *
37
+ * Keys are restricted to letters, `_` and `-` for the same reason: the
38
+ * minimal readers this dialect targets match keys with that charset and
39
+ * SKIP a line they do not match, which turns a key containing a digit into
40
+ * a silently dropped field. A refusal at compose time is the better
41
+ * failure.
42
+ *
43
+ * ## Refusals arrive in the door's own taxonomy
44
+ *
45
+ * A composition refusal throws `RecordDoorError` with code
46
+ * `invalid_record` — exactly what the door answers for a body it cannot
47
+ * accept. A form handler therefore has ONE error surface for "this filing
48
+ * is malformed", whether the composer caught it or the store did, and
49
+ * neither becomes a 500.
50
+ */
51
+ /** How one field renders into the frontmatter block. */
52
+ export type RecordFieldRender = "scalar" | "list" | "folded" | "map";
53
+ export type RecordFieldDescriptor = {
54
+ /** The frontmatter key. Letters, `_` and `-` only. */
55
+ key: string;
56
+ /** The input key this field reads. Defaults to `key`. */
57
+ from?: string;
58
+ /** Default `"scalar"`. */
59
+ render?: RecordFieldRender;
60
+ /** A missing or empty required field refuses. */
61
+ required?: boolean;
62
+ /**
63
+ * A fixed value, written whatever the input says — the mechanical
64
+ * fields a family always carries (`type: finding`). An input that also
65
+ * supplies the key refuses, rather than one of the two silently winning.
66
+ */
67
+ constant?: string;
68
+ };
69
+ export type RecordFamilyDescriptor = {
70
+ /** The family name, as the consumer's schema domain spells it. */
71
+ family: string;
72
+ /** The fields, in the order they are written. Order is the bytes. */
73
+ fields: RecordFieldDescriptor[];
74
+ /**
75
+ * The input key carrying the markdown prose that follows the block.
76
+ * Omitted, the family files as frontmatter alone.
77
+ */
78
+ prose?: string;
79
+ /** Whether the prose must be present and non-empty. */
80
+ proseRequired?: boolean;
81
+ /**
82
+ * The id prefix this family's records carry, e.g. `"dec"`. Declared, the
83
+ * composer checks that the composed id matches the contract's id scheme
84
+ * AND carries this prefix — so a form that files a decision under a
85
+ * case's id is refused here rather than at the door.
86
+ *
87
+ * The prefix vocabulary is the consumer's; only the *scheme* is the
88
+ * contract's.
89
+ */
90
+ idPrefix?: string;
91
+ /** Which frontmatter key holds the id. Defaults to `"id"`. */
92
+ idKey?: string;
93
+ };
94
+ export type RecordBodyComposer = {
95
+ /** The families this composer knows, in declaration order. */
96
+ readonly families: readonly string[];
97
+ /** Compose one family's canonical body bytes from a form's input. */
98
+ compose(family: string, input: Record<string, unknown>): string;
99
+ };
100
+ /**
101
+ * The contract's id scheme: `<prefix>-<yyyy-mm-dd>-<slug>`, with an
102
+ * optional trailing ordinal that the slug charset already admits. The
103
+ * scheme belongs to the door, which refuses `invalid_id` on anything else;
104
+ * the *prefix vocabulary* belongs to the consumer, which is why a
105
+ * descriptor supplies the prefix and this pattern supplies the shape.
106
+ */
107
+ export declare const RECORD_ID_PATTERN: RegExp;
108
+ /** Mint an id under that scheme. Refuses rather than emitting one the door
109
+ * would reject. */
110
+ export declare function mintRecordId(input: {
111
+ prefix: string;
112
+ date: string;
113
+ slug: string;
114
+ ordinal?: number;
115
+ }): string;
116
+ /**
117
+ * Build a composer over the consumer's family descriptors.
118
+ *
119
+ * Descriptors are validated once, here, rather than at every filing: a
120
+ * malformed descriptor is a programming error and belongs at module load,
121
+ * not in a user's form submission.
122
+ */
123
+ export declare function createRecordBodyComposer(descriptors: readonly RecordFamilyDescriptor[]): RecordBodyComposer;
124
+ /**
125
+ * This package's frontmatter reader: `key: value`, `key: [a, b]`, `key: >-`
126
+ * folded blocks rejoined with single spaces, and one-level maps of two-space
127
+ * `sub: value` lines whose sub-values may THEMSELVES fold. Keys are letters,
128
+ * `_` and `-`; a line matching nothing is skipped, which is what minimal
129
+ * readers do and why the composer refuses to emit one.
130
+ *
131
+ * ## The reader accepts a superset of what the writer emits
132
+ *
133
+ * The reader is deliberately NOT the writer's mirror, and saying so plainly
134
+ * is the point of this paragraph. The composer's write dialect is the small,
135
+ * refusing one documented at the top of this module — it never emits a folded
136
+ * map sub-value, and it refuses any input it cannot render in its four forms.
137
+ * The reader, though, reads a corpus written by OTHER writers, and the record
138
+ * substrate's own reader accepts more than we emit. Reading less than the
139
+ * substrate does would make a mount unable to read real records the substrate
140
+ * happily serves, so the reader matches the substrate's reader instead:
141
+ *
142
+ * - inside an open map, ` sub: >-` **or** ` sub:` (an empty sub-value)
143
+ * opens a folded sub-value; the following indented lines that are not
144
+ * themselves two-space `sub: value` lines append, rejoined with single
145
+ * spaces. A new two-space `sub: value` line closes the open fold; a
146
+ * non-indented line closes the map;
147
+ * - a TAB-led line counts as indented, exactly as the substrate's reader
148
+ * counts it — treating it as top-level would silently drop a continuation.
149
+ *
150
+ * Note the asymmetry is one-directional and safe in the direction that
151
+ * matters: every byte the composer emits still reads back to what it wrote
152
+ * (the round-trip assertion in `composeOne` is what enforces that), while
153
+ * bytes the composer would refuse to write may still be read.
154
+ *
155
+ * At top level `key:` (empty) opens a MAP, not a fold — the substrate does
156
+ * the same, and the `sub:`-opens-a-fold rule applies only INSIDE a map.
157
+ *
158
+ * It lives beside the writer on purpose. A dialect with its reader and its
159
+ * writer in two modules is a dialect with two definitions, and the drift
160
+ * shows up as bytes nobody can explain.
161
+ */
162
+ export declare function parseRecordFrontmatter(text: string): Record<string, unknown>;
163
+ /** The prose beneath a composed body's frontmatter block. */
164
+ export declare function recordProseOf(text: string): string;
165
+ //# sourceMappingURL=body.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body.d.ts","sourceRoot":"","sources":["../src/body.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAIH,wDAAwD;AACxD,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG;IAClC,sDAAsD;IACtD,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,qBAAqB,EAAE,CAAC;IAChC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,qEAAqE;IACrE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;CACjE,CAAC;AAcF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,QAAqD,CAAC;AAEpF;oBACoB;AACpB,wBAAgB,YAAY,CAAC,KAAK,EAAE;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAuBT;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,SAAS,sBAAsB,EAAE,GAC7C,kBAAkB,CA2DpB;AAoJD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAsE5E;AAED,6DAA6D;AAC7D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD"}