@kairos-es/codec 0.0.0 → 1.0.0-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairos-es/codec",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-alpha.1",
4
4
  "description": "Schema-first codec with encryption seam for kairos-es",
5
5
  "license": "BSD-3-Clause",
6
6
  "repository": {
@@ -12,7 +12,7 @@
12
12
  "homepage": "https://github.com/neverbland/kairos-es",
13
13
  "peerDependencies": {
14
14
  "effect": "^3.22.0",
15
- "@kairos-es/core": "^0.0.0"
15
+ "@kairos-es/core": "^1.0.0-alpha.1"
16
16
  },
17
17
  "main": "./dist/cjs/index.js",
18
18
  "module": "./dist/esm/index.js",
package/src/decider.ts CHANGED
@@ -66,6 +66,7 @@ import { Effect } from 'effect'
66
66
  import { buildDecisionModel, type RequirementOf } from './decisionModel'
67
67
  import type { CodecError } from './errors'
68
68
  import type { SliceProjection } from './projection'
69
+ import type { TaglessSliceInLedger } from './readLedger'
69
70
  import type { Serializer } from './Serializer'
70
71
  import type { Uuid } from './Uuid'
71
72
 
@@ -193,8 +194,15 @@ export const decider = <
193
194
  * (`buildDecisionModel`) and produce (`toEvent`) sides, `Uuid` through produce,
194
195
  * the slices' `RequirementOf<P>` through decode, and `R` through produce. (For a
195
196
  * def whose `Schema` carries a requirement, `RequirementOf<P>` and `R` are the
196
- * same tag.) Any `decide` failure, a decode `CodecError`, or an
197
- * `AppendConditionFailed` propagates on the error channel unchanged.
197
+ * same tag.) Any `decide` failure, a decode `CodecError`, an
198
+ * `AppendConditionFailed`, or the read's own `TaglessSliceInLedger` propagates on
199
+ * the error channel unchanged.
200
+ *
201
+ * ⚠ INSIDE a bracket this function is REFUSED, as a defect, at the store's shared
202
+ * append decorator, and `decideOnly` is what a body reaches for instead.
203
+ * `index.ts` states that pair, why each refuses exactly where the other is the
204
+ * answer, and why neither can be expressed over the other. Read it before
205
+ * changing either signature; this file does not restate the argument.
198
206
  */
199
207
  export const runDecider = <
200
208
  P extends Record<string, SliceProjection>,
@@ -206,7 +214,7 @@ export const runDecider = <
206
214
  command: Command,
207
215
  ): Effect.Effect<
208
216
  Position,
209
- E | CodecError | AppendConditionFailed,
217
+ E | CodecError | AppendConditionFailed | TaglessSliceInLedger,
210
218
  DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
211
219
  > =>
212
220
  Effect.gen(function* () {
@@ -7,11 +7,18 @@
7
7
  * `modelAtHead` stops where the fold does, at `{ state, head }` — a
8
8
  * materialisation of the record with no table, no checkpoint and no runner.
9
9
  * `buildDecisionModel` adds the only part that is genuinely decision-specific:
10
- * the `AppendCondition` derived from the same query and that same head (the DCB
11
- * optimistic-concurrency contract). It is core's old
12
- * `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
10
+ * the `AppendCondition` (the DCB optimistic-concurrency contract). It is core's
11
+ * old `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
13
12
  * because decode needs `Schema` + the `Serializer`.
14
13
  *
14
+ * BOTH exports also record into the ambient read ledger when one is in context,
15
+ * which is what makes a decision that walks a chain of identifiers guard on every
16
+ * hop rather than on its last. Recording is `modelAtHead`'s, so it happens
17
+ * whatever symbol performed the read; outside a bracket there is no ledger,
18
+ * nothing is recorded and no behaviour changes. `readLedger.ts` owns the whole
19
+ * argument, including why the recorder is this module rather than a
20
+ * layered-only entry point of its own.
21
+ *
15
22
  * The fold stays pure and in core; only the decode stage is effectful, and that
16
23
  * stage lives in `decode.ts` — `decodeSlices` — so the read side folds a live
17
24
  * `subscribe` stream through the *same* decode this module folds a bounded
@@ -32,10 +39,16 @@ import {
32
39
  DcbEventStore,
33
40
  type Position,
34
41
  } from '@kairos-es/core'
35
- import { Effect, Stream } from 'effect'
42
+ import { Effect, Option, Stream } from 'effect'
36
43
  import { decodeSlices } from './decode'
37
44
  import type { CodecError } from './errors'
38
45
  import type { SliceProjection } from './projection'
46
+ import {
47
+ ambientLayeredCondition,
48
+ assertServableAddition,
49
+ recordQuery,
50
+ type TaglessSliceInLedger,
51
+ } from './readLedger'
39
52
  import type { Serializer } from './Serializer'
40
53
 
41
54
  /** The union of requirements `R` carried by a record of `SliceProjection`s. */
@@ -66,6 +79,16 @@ export type RequirementOf<T extends Record<string, SliceProjection>> =
66
79
  * single fold cannot serve — across many identities, ordered, joined, paginated
67
80
  * — and never because it is fresher, because it is not.
68
81
  *
82
+ * ⚠ AND INSIDE A BRACKET IT IS NOT THE FLOOR THE APPEND WILL USE. Everything
83
+ * above is a statement about THIS read, and it stays true; what changes is that a
84
+ * bracket guards on the EARLIEST head across every hop it walked, which on any hop
85
+ * after the first is a strictly lower number than the one returned here. So a
86
+ * caller who reads `head` inside a bracket and reasons about what the append will
87
+ * refuse is reasoning from the wrong figure — the guard is wider than this head
88
+ * says. Nothing here is wrong; the trap is assuming the two are the same number,
89
+ * and it is named because it is exactly the assumption that holds outside a
90
+ * bracket and quietly stops holding inside one. `readLedger.ts` owns the floor.
91
+ *
69
92
  * `head` is LOG-GLOBAL: it is the head the store's `read` returned, so it moves
70
93
  * for an event no slice here selects. `test/decisionModel.test.ts` pins that
71
94
  * behaviour EXPLICITLY, over four appended events one of which the composed query
@@ -93,7 +116,7 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
93
116
  projections: T,
94
117
  ): Effect.Effect<
95
118
  { readonly state: CompositeState<T>; readonly head: Position },
96
- CodecError,
119
+ CodecError | TaglessSliceInLedger,
97
120
  DcbEventStore | Serializer | RequirementOf<T>
98
121
  > =>
99
122
  Effect.gen(function* () {
@@ -108,6 +131,21 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
108
131
  const composite = composeProjections(
109
132
  projections as Record<string, AnyProjection>,
110
133
  )
134
+
135
+ // Both ledger calls below are no-ops outside a bracket and neither touches
136
+ // the requirement channel; `readLedger.ts` owns why recording happens HERE
137
+ // rather than at a layered-only entry point of its own.
138
+ //
139
+ // Refuse an unservable union BEFORE the read is paid for: the question is
140
+ // answerable from the queries alone, so a hop that would kill the append two
141
+ // layers away fails at the read that caused it instead.
142
+ yield* assertServableAddition(projections as Record<string, AnyProjection>)
143
+
144
+ // EAGER: the query is filed before a single event is streamed, so a read that
145
+ // opens and never completes still widens the guard. What comes back is the
146
+ // effect that completes the hop; outside a bracket it does nothing.
147
+ const closeHop = yield* recordQuery(composite.query)
148
+
111
149
  const { events, head } = yield* store.read(composite.query)
112
150
 
113
151
  const decoded = decode(events)
@@ -118,6 +156,11 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
118
156
  (acc, event) => composite.evolve(acc, event),
119
157
  )) as CompositeState<T>
120
158
 
159
+ // LAZY: only once the fold has run to completion. A read that failed
160
+ // part-way folded a PREFIX, and a head recorded for it is a claim it did not
161
+ // earn — so the line below is unreachable on that path, deliberately.
162
+ yield* closeHop(head)
163
+
121
164
  return { state, head }
122
165
  })
123
166
 
@@ -134,17 +177,19 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
134
177
  * of a near-duplicate: `modelAtHead` is the shared part, this function is the
135
178
  * condition and nothing else, and each is shorter than the one function was.
136
179
  *
137
- * `composeProjections` therefore runs TWICE for one command — once inside
138
- * `modelAtHead` for the read, once here for `failIfEventsMatch` — and that is
139
- * deliberate rather than waste. It is a pure derivation of the record, so the two
140
- * queries cannot disagree however either call site changes. The alternative,
141
- * returning the query from `modelAtHead`, would put a third field on a READ-TIME
142
- * API that no read-time caller wants, to save one pure merge over a handful of
143
- * slices; a surface paying for a body's convenience is the worse trade.
180
+ * Outside a bracket `composeProjections` therefore runs twice for one command —
181
+ * once inside `modelAtHead` for the read, once here for `failIfEventsMatch` — and
182
+ * that is deliberate rather than waste. It is a pure derivation of the record, so
183
+ * the two queries cannot disagree however either call site changes. The
184
+ * alternative, returning the query from `modelAtHead`, would put a third field on
185
+ * a READ-TIME API that no read-time caller wants, to save one pure merge over a
186
+ * handful of slices; a surface paying for a body's convenience is the worse
187
+ * trade. Inside a bracket the second merge is not reached at all: the ledger's
188
+ * own derivation composes every recorded hop instead.
144
189
  *
145
190
  * ## How to attribute the read, the decode and the fold — the rule, stated once
146
191
  *
147
- * Because this function is `Effect.map` over `modelAtHead`, prose about the codec
192
+ * Because this function is expressed OVER `modelAtHead`, prose about the codec
148
193
  * splits into two kinds and only one of them may name this symbol:
149
194
  *
150
195
  * - Naming this function as the ENTRY POINT a caller invokes, or as the path
@@ -168,15 +213,34 @@ export const buildDecisionModel = <T extends Record<string, SliceProjection>>(
168
213
  readonly state: CompositeState<T>
169
214
  readonly appendCondition: AppendCondition
170
215
  },
171
- CodecError,
216
+ CodecError | TaglessSliceInLedger,
172
217
  DcbEventStore | Serializer | RequirementOf<T>
173
218
  > =>
174
- Effect.map(modelAtHead(projections), ({ state, head }) => ({
175
- state,
176
- appendCondition: {
177
- failIfEventsMatch: composeProjections(
178
- projections as Record<string, AnyProjection>,
179
- ).query,
180
- after: head,
181
- },
182
- }))
219
+ Effect.gen(function* () {
220
+ const { state, head } = yield* modelAtHead(projections)
221
+
222
+ // INSIDE A BRACKET the condition comes from the ledger — every query the
223
+ // bracket walked, at the earliest head observed — and not from this one read.
224
+ // That is the change's whole ergonomic claim: the call whose current
225
+ // behaviour is the hazard becomes CORRECT inside a bracket, with nothing at
226
+ // the call site changing beyond entering one. The cost is that one expression
227
+ // means two things depending on ambient context, and that is the trade taken.
228
+ //
229
+ // `modelAtHead` has just recorded a hop, so inside a bracket the derivation
230
+ // cannot be empty; `None` here means there is no ledger at all.
231
+ const ambient = yield* ambientLayeredCondition
232
+ if (Option.isSome(ambient)) {
233
+ return { state, appendCondition: ambient.value }
234
+ }
235
+
236
+ // OUTSIDE a bracket this is byte-for-byte what shipped.
237
+ return {
238
+ state,
239
+ appendCondition: {
240
+ failIfEventsMatch: composeProjections(
241
+ projections as Record<string, AnyProjection>,
242
+ ).query,
243
+ after: head,
244
+ },
245
+ }
246
+ })
package/src/index.ts CHANGED
@@ -34,6 +34,55 @@
34
34
  * error union are written once, in the two function bodies, rather than restated
35
35
  * as an annotation above them.
36
36
  *
37
+ * `transact(precondition)(body)` is the layered write path, for a decision whose
38
+ * second query cannot be written until an earlier read has been folded. It opens
39
+ * an ambient read ledger, evaluates the precondition inside it, hands that
40
+ * precondition's output to the body as a service, and appends what the body
41
+ * returned under the union of every query either of them read, at the earliest
42
+ * head observed. No condition reaches the caller. `unsafeAppend(events, reason)`
43
+ * is the one sanctioned way out. `precondition.ts` owns the argument for the
44
+ * bracket and everything on it, and `readLedger.ts` for the ledger the seam rests
45
+ * on — including why the derivation itself is deliberately not on this surface.
46
+ *
47
+ * ## ⚠ `runDecider` and `decideOnly` are TWO DISPOSITIONS OF ONE CYCLE
48
+ *
49
+ * They are read as a pair or not at all, because each refuses exactly where the
50
+ * other is the answer:
51
+ *
52
+ * | | outside a bracket | inside a bracket |
53
+ * | ------------ | ---------------------------------------- | ----------------------------------- |
54
+ * | `runDecider` | ✅ the ordinary single-hop case | ⛔ a DEFECT, refused at the store |
55
+ * | `decideOnly` | ⛔ unrunnable, and `tsc` says so | ✅ the layered case |
56
+ *
57
+ * ⚠ *Unrunnable* rather than *unwritable*, and the difference is measured. A bare
58
+ * `decideOnly(d, cmd)` outside a bracket COMPILES, and so does an exported helper
59
+ * over it: its `ReadLedger` requirement simply propagates, as any unmet requirement
60
+ * does. The refusal lands where the context is DISCHARGED — an `Effect.provide`, a
61
+ * `runPromise`, an annotation asking for `R = never` — which may be another file.
62
+ * So `pnpm check` catches it without anything running, which is the whole gain over
63
+ * the runtime defect this replaced; what it does not do is underline the call.
64
+ *
65
+ * Neither is expressed over the other, and that is forced rather than chosen: a
66
+ * definition of `runDecider` as *"`decideOnly`, then append"* would have to call
67
+ * `decideOnly` outside a bracket, which does not typecheck, and the append it then
68
+ * made inside one would be refused. The two refusals are the same fact seen from
69
+ * either side — an append inside a bracket belongs to the bracket — so any
70
+ * expression of one over the other has to defeat one of them.
71
+ *
72
+ * ⚠ The two refusals arrive at DIFFERENT MOMENTS, which is the part a caller acts
73
+ * on. `runDecider` inside a bracket is refused when it RUNS, at core's shared append
74
+ * decorator — so the same refusal also meets a raw `store.append` and a runner one
75
+ * call away in a helper. `decideOnly` outside a bracket is refused by `tsc`, at the
76
+ * discharge point above, by naming `ReadLedger` in its requirement channel. Why each
77
+ * was given the instrument it was is a DECISION and belongs to the record that took
78
+ * it: ADR-0014's `Status`. A lint rule reaches the first case earlier still, while it
79
+ * is being written; `biome-plugins/README.md` in the source repository owns what a
80
+ * rule there can and cannot see.
81
+ *
82
+ * The remedy is one identifier away in each direction, which is the whole reason a
83
+ * refusal is affordable at all. `decider.ts` and `precondition.ts` carry the two
84
+ * halves.
85
+ *
37
86
  * `modelAtHead(slices)` is that same read-decode-fold WITHOUT the
38
87
  * `AppendCondition`: `{ state, head }`, one slice record materialised at read
39
88
  * time with no table, no checkpoint and no runner, current as of the `head` its
@@ -60,11 +109,24 @@ export {
60
109
  export { decodeSlices } from './decode'
61
110
  export { CodecError, UnknownEventType } from './errors'
62
111
  export { type EventDef, type EventDefAny, event, type TagsFor } from './event'
112
+ export {
113
+ decideOnly,
114
+ type Precondition,
115
+ transact,
116
+ unsafeAppend,
117
+ } from './precondition'
63
118
  export {
64
119
  type ProjectionBuilder,
65
120
  projection,
66
121
  type SliceProjection,
67
122
  } from './projection'
123
+ export {
124
+ type CloseHop,
125
+ type Hop,
126
+ NoReadsRecorded,
127
+ ReadLedger,
128
+ TaglessSliceInLedger,
129
+ } from './readLedger'
68
130
  export { makeRegistry, type Registry } from './registry'
69
131
  export { Serializer, SerializerDefault } from './Serializer'
70
132
  export { toEvent } from './toEvent'
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The bracket that owns the append, the precondition value it takes, and the two
3
+ * functions that make a decision inside it writable.
4
+ *
5
+ * ## What the bracket is for, and why no condition reaches the caller
6
+ *
7
+ * `transact(precondition)(body)` evaluates a guard, hands its output to the body
8
+ * as a service, and appends whatever the body produced under the union of every
9
+ * read EITHER of them performed. A caller writes no condition, no union, no floor
10
+ * and no `min`.
11
+ *
12
+ * That is not an ergonomic preference. Owning the append does not make a wrong
13
+ * condition a type error — it makes one UNWRITABLE, which is the stronger
14
+ * guarantee: a returned condition is a condition a caller can decline to use, and
15
+ * a decision that looks guarded and is not is the exact failure this seam exists
16
+ * to close. It is what Axon Framework 5 does, where the condition is married to
17
+ * the events at prepare-commit and the caller never holds one.
18
+ *
19
+ * One thing is deliberately NOT borrowed from that prior art. There, an append
20
+ * with no prior read leaves the condition unset and the append unguarded, so the
21
+ * method name reads as unconditional while the behaviour is not. `NoReadsRecorded`
22
+ * closes that door here and must not be lost.
23
+ *
24
+ * ⚠ The name says *declare, then do*. It is NOT a database transaction: an append
25
+ * under a condition is not two-phase, and nothing rolls back.
26
+ *
27
+ * ## Order is the design, and it is the one line here that must not move
28
+ *
29
+ * The precondition is evaluated INSIDE the bracket and never before it. Evaluated
30
+ * before, its reads are not part of what it protects — the ledger does not exist
31
+ * yet, so its hops are lost and the write is guarded only by the business read.
32
+ * The check would look exactly as it does now and would guard nothing.
33
+ *
34
+ * ## Why the precondition's output crosses as a SERVICE
35
+ *
36
+ * A body that needs authorisation names the tag in its requirement channel, so
37
+ * `transact` must provide it, so the body CANNOT BE RUN without a precondition.
38
+ * That is a PRESENCE check, and presence is the one direction the type system can
39
+ * decide here — `Exclude<R, Id>` in the return type is where it is spent. The
40
+ * alternative, a curried parameter, buys nothing equivalent: a body is free to
41
+ * ignore a parameter.
42
+ *
43
+ * ## Composition is a combinator, not a list
44
+ *
45
+ * `transact` takes exactly ONE precondition. A variadic argument asserts
46
+ * INDEPENDENCE, and the interesting case is DEPENDENT — *may act for this
47
+ * organisation*, then *may sign in that organisation* — which a flat list cannot
48
+ * express and which is the very shape this change exists for. A dependent pair
49
+ * needs nothing anyway: a precondition is an ordinary `Effect` evaluated inside
50
+ * the open ledger, so two compose with `Effect.gen` and both sets of reads land in
51
+ * the same condition. An INDEPENDENT set wants a combinator over a RECORD,
52
+ * evaluated SEQUENTIALLY — fail-fast is the point, and concurrent reads are
53
+ * precisely the case where the earliest-head reduction stops being redundant. That
54
+ * combinator is purely additive and is not shipped here.
55
+ *
56
+ * ## Conflict retry goes OUTSIDE the bracket, never inside
57
+ *
58
+ * `retryOnConflict(transact(p)(body))`, never a retry within the body. The bracket
59
+ * takes an opaque `Effect`, so no type can express that rule and the lint gate is
60
+ * what enforces it: in the source repository, `biome-plugins/noRetryInBracket.grit`
61
+ * owns why and `test/retryInBracket.test.ts` executes it.
62
+ *
63
+ * ## ⚠⚠ THE ONE MISTAKE NOTHING CATCHES: a view read inside a bracket
64
+ *
65
+ * **A projection read, a raw SQL read, or any read that does not fold through
66
+ * `modelAtHead`, performed inside a bracket, contributes NO HOP.** Its query is
67
+ * absent from the derived union and its head is absent from the floor, so the
68
+ * decision rests on data the condition does not guard — and the bracket looks
69
+ * guarded, which is the exact failure this seam exists to close, wearing the fix's
70
+ * own name.
71
+ *
72
+ * **Nothing will tell you — not here and not in your own tree.** Not the type
73
+ * system: a requirement channel is ADDITIVE, so it can demand a capability's
74
+ * presence and never its absence. Not `NoReadsRecorded`: that fires on an EMPTY
75
+ * bracket and never on an under-populated one, so a bracket holding one genuine fold
76
+ * beside a view read passes it. And not a lint rule — one was written for exactly
77
+ * this, kept for a while and deleted, because a syntactic gate has no call graph and
78
+ * no types, so it could only name a receiver by convention, and because a lint
79
+ * plugin runs only where its own config is and so never travels with the library.
80
+ * `biome-plugins/README.md` in the source repository records the test that rule
81
+ * failed.
82
+ *
83
+ * So this is a rule a reader keeps, and knowing it is the guarantee: **inside a
84
+ * bracket, read through `modelAtHead`. If a read must
85
+ * consult a materialised view, do it OUTSIDE the bracket** — where it is ordinary
86
+ * code, because its answer is then not part of what the append is guarded on.
87
+ * ADR-0014's *decision read* rule is the test for which one a given read is.
88
+ */
89
+ import type { AppendConditionFailed, DcbEvent, Position } from '@kairos-es/core'
90
+ import { AppendIsOwned, DcbEventStore } from '@kairos-es/core'
91
+ import type { Array as Arr } from 'effect'
92
+ import { Context, Effect, Layer } from 'effect'
93
+ import type { Decider } from './decider'
94
+ import { modelAtHead, type RequirementOf } from './decisionModel'
95
+ import type { CodecError } from './errors'
96
+ import type { SliceProjection } from './projection'
97
+ import {
98
+ deriveLayeredCondition,
99
+ makeReadLedger,
100
+ NoReadsRecorded,
101
+ ReadLedger,
102
+ type TaglessSliceInLedger,
103
+ } from './readLedger'
104
+ import type { Serializer } from './Serializer'
105
+ import type { Uuid } from './Uuid'
106
+
107
+ /**
108
+ * A named, reusable guard plus the tag its output is handed across.
109
+ *
110
+ * A VALUE and not a function of the command, deliberately: the point of the seam
111
+ * is that a guard is written once, centrally, and referenced by however many
112
+ * commands need it. A command supplies its own arguments when it BUILDS the value
113
+ * (`mayActFor(userId, orgId, verb)`), and what `transact` receives is the built
114
+ * thing.
115
+ *
116
+ * `Service` is fully generic and the library never reads it. Every consumer-side
117
+ * authorisation shape — a plain resolved identifier, a branded proof, a gate with
118
+ * a private minting closure — is application code this genericity accommodates
119
+ * without the library knowing anything about it. That is a requirement rather than
120
+ * an incidental property: hard-coding it would foreclose a consumer's later
121
+ * choice, and it costs nothing to keep.
122
+ *
123
+ * ⚠ `evaluate` is an `Effect`, so it is a FIELD OF AN OBJECT LITERAL and its
124
+ * expression is evaluated when the `Precondition` VALUE is built — before the
125
+ * bracket opens and before any cheap check inside it runs. Anything a builder does
126
+ * eagerly there (parsing an argument, resolving an identifier) happens outside the
127
+ * ordering guarantee above, and a throw there is an uncatchable defect on the very
128
+ * path meant to deny the input cheaply. Wrap such work in `Effect.suspend`.
129
+ */
130
+ export interface Precondition<Id, Service, E, R> {
131
+ readonly tag: Context.Tag<Id, Service>
132
+ readonly evaluate: Effect.Effect<Service, E, R>
133
+ }
134
+
135
+ /**
136
+ * The DCB command cycle MINUS the append: read the decision model, decode, fold,
137
+ * decide, and RETURN the events for the bracket to append.
138
+ *
139
+ * Bracket-only, and the refusal is STATIC — see the section below for the mechanism
140
+ * and, more importantly, for where the error actually lands, which is not where a
141
+ * reader first expects. Forced through anyway, being outside a bracket is a DEFECT
142
+ * rather than an error-channel value: it is not a data-dependent outcome a caller
143
+ * could handle, and the events produced would be dropped with nothing to append them.
144
+ *
145
+ * It is also what makes the nesting refusal liveable. A command runner inside a
146
+ * bracket is refused at the store, and this is the function a builder reaches for
147
+ * instead — one identifier away. A refusal is only affordable when the fix is that
148
+ * close.
149
+ *
150
+ * `modelAtHead` and NOT `buildDecisionModel`: this function must not derive a
151
+ * condition at all. The bracket owns that, and owning it in ONE place is what
152
+ * stops a caller appending under the last hop's condition alone.
153
+ *
154
+ * ## Bracket-only is a REQUIREMENT, so `tsc` refuses it rather than a run
155
+ *
156
+ * `ReadLedger` is named in the requirement channel and `transact` is the only thing
157
+ * that provides it, so a program that calls this outside a bracket cannot be RUN,
158
+ * and `tsc` is what says so — nothing has to execute for a caller to be told.
159
+ *
160
+ * ⚠ Measured, because the obvious stronger reading is wrong: the call site itself
161
+ * COMPILES. A bare `decideOnly(d, cmd)` outside a bracket is fine, and so is an
162
+ * exported helper over it; the requirement just propagates, as any unmet requirement
163
+ * does. What fails is the DISCHARGE — `Effect.provide`, `runPromise`, an annotation
164
+ * asking for `R = never` — possibly in another file. That is still a static refusal
165
+ * and it is the whole gain over the runtime defect this replaced. It is not a
166
+ * squiggle under the mistake.
167
+ *
168
+ * That is a PRESENCE check, and presence is the direction the type system can
169
+ * decide here — the same direction `transact`'s `Exclude<R, Id>` already spends on
170
+ * the precondition's output. ADR-0014's 2026-08-29 amendment carries the rule that
171
+ * follows: look for a presence check before reaching for a defect. This function
172
+ * shipped a defect first, and the check was available all along.
173
+ *
174
+ * ⚠ Two lines carry it and their JOBS differ. The RETURN ANNOTATION is the surface a
175
+ * consumer is judged against, and what the discharge above meets. The
176
+ * `yield* ReadLedger` keeps the requirement in the INFERRED type, so it survives the
177
+ * annotation being edited, and it dies at run time for the audience no type reaches
178
+ * — a JavaScript consumer runs no compile step — rather than handing back events
179
+ * with no bracket to append them. Nothing here READS the ledger; `modelAtHead` does
180
+ * the recording, ambiently, through `Effect.serviceOption`.
181
+ *
182
+ * ⚠ Do not try to write down which check catches which deletion. It was tried twice
183
+ * and was wrong twice, and `test/appendOwnership.test.ts` in the source repository
184
+ * says why the second failure means there should be no third attempt. Plant the
185
+ * mutation instead.
186
+ */
187
+ export const decideOnly = <
188
+ P extends Record<string, SliceProjection>,
189
+ Command,
190
+ E,
191
+ R,
192
+ >(
193
+ decider: Decider<P, Command, E, R>,
194
+ command: Command,
195
+ ): Effect.Effect<
196
+ Arr.NonEmptyReadonlyArray<DcbEvent>,
197
+ E | CodecError | TaglessSliceInLedger,
198
+ ReadLedger | DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
199
+ > =>
200
+ Effect.gen(function* () {
201
+ yield* ReadLedger
202
+
203
+ const { state } = yield* modelAtHead(decider.projections(command))
204
+ return yield* decider.decide(state, command)
205
+ })
206
+
207
+ /**
208
+ * The sanctioned exception to the nesting refusal: append these events under NO
209
+ * condition, and say in the identifier and in a required `reason` why that is
210
+ * deliberate.
211
+ *
212
+ * It covers three cases and no others: deliberate narrowing, a genuinely
213
+ * unconditional creation, and a second append inside one bracket. The `reason` is
214
+ * ceremony on purpose, and ADR-0014 decision 6 owns why a hatch has to cost more
215
+ * than the safe path — that argument is a DECISION and belongs to the record that
216
+ * took it, so it is not restated here. How biome turns a required argument into a
217
+ * review signal is the source repository's `biome-plugins/unsafeAppendReported.grit`.
218
+ *
219
+ * ⚠ It BYPASSES the derived condition rather than composing with it. A caller who
220
+ * genuinely needs *the union, minus one event type* must restate the whole
221
+ * condition here. If that turns up in practice, a composing override is the
222
+ * additive answer and this hatch is not it.
223
+ *
224
+ * `Effect.mapInputContext` over
225
+ * `Context.omit(AppendIsOwned)` is what lets it through the store's ownership
226
+ * refusal — the one route, and `AppendIsOwned.ts` owns why removing the marker is
227
+ * the shape rather than a second tag or a sanctioning flag.
228
+ */
229
+ export const unsafeAppend = (
230
+ events: Arr.NonEmptyReadonlyArray<DcbEvent>,
231
+ // Required by the signature and never read, which IS the mechanism: the value a
232
+ // caller has to write is what a reviewer sees in the diff, and `tsc` refuses the
233
+ // call that omits it. A lint rule asserting the same arity existed and was
234
+ // deleted, on the ground that the type already refuses it — which made that
235
+ // ground load-bearing, so `test/appendOwnership.test.ts` in the source repository
236
+ // pins it. Reading the value here would give it a second job and a second place to
237
+ // be wrong.
238
+ //
239
+ // NOT `_reason`, though it is unused. This name SHIPS — it is what a consumer's
240
+ // editor offers and what the published `.d.ts` carries — and a leading underscore
241
+ // is this ecosystem's way of saying "ignore me", which is the opposite of what a
242
+ // required argument whose whole purpose is to be read by a human should say.
243
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: unread on purpose; this name ships in the .d.ts and an underscore would tell a consumer to ignore it
244
+ reason: string,
245
+ ): Effect.Effect<Position, AppendConditionFailed, DcbEventStore> =>
246
+ Effect.flatMap(DcbEventStore, (store) =>
247
+ // ⛔ DO NOT SIMPLIFY THIS. The annotation reads as "map an empty context to an
248
+ // empty context", and the types say nothing else — but `Context.omit` runs
249
+ // against the RUNTIME context, which inside a bracket holds `AppendIsOwned`,
250
+ // and removing it is the entire reason this function can append at all. That
251
+ // marker is deliberately absent from every signature (it is read through
252
+ // `Effect.serviceOption`, which is what keeps it out of `append`'s requirement
253
+ // channel), so nothing here will fail to compile if this is deleted — what
254
+ // catches it is `test/appendOwnership.test.ts` in the source repository, not the
255
+ // type checker.
256
+ Effect.mapInputContext(
257
+ store.append(events),
258
+ (context: Context.Context<never>) => Context.omit(AppendIsOwned)(context),
259
+ ),
260
+ )
261
+
262
+ /**
263
+ * Open a bracket over ONE decision: evaluate the precondition inside it, hand its
264
+ * output to the body under the precondition's tag, and append what the body
265
+ * produced under the union of every read either of them performed.
266
+ *
267
+ * Curried so the precondition reads as the thing the command DECLARES and the body
268
+ * reads as the thing it DOES.
269
+ *
270
+ * The ownership marker is provided ALONGSIDE the ledger and to the body only, so
271
+ * any append inside the body — a nested bracket, a command runner, a raw
272
+ * `store.append` — is refused at the store as a defect, while the bracket's own
273
+ * closing append below runs outside that scope and is unaffected. That is why the
274
+ * refusal is on APPENDING rather than on opening a bracket: a command runner
275
+ * appends without opening one, and its events usually match the union the bracket
276
+ * is about to guard on, so the bracket's own close would be refused by a condition
277
+ * its own body invalidated.
278
+ */
279
+ export const transact =
280
+ <Id, Service, PE, PR>(precondition: Precondition<Id, Service, PE, PR>) =>
281
+ <E, R>(
282
+ body: Effect.Effect<Arr.NonEmptyReadonlyArray<DcbEvent>, E, R>,
283
+ ): Effect.Effect<
284
+ Position,
285
+ E | PE | NoReadsRecorded | AppendConditionFailed,
286
+ | Exclude<Exclude<R, Id>, ReadLedger | AppendIsOwned>
287
+ | Exclude<PR, ReadLedger | AppendIsOwned>
288
+ | DcbEventStore
289
+ > =>
290
+ Effect.gen(function* () {
291
+ const store = yield* DcbEventStore
292
+ const ledger = yield* makeReadLedger
293
+
294
+ const events = yield* Effect.gen(function* () {
295
+ // ORDER IS THE DESIGN. The precondition runs first and runs HERE, with the
296
+ // ledger already in context, so its reads are hops in the very condition
297
+ // that will guard the body's write.
298
+ const proof = yield* precondition.evaluate
299
+ return yield* body.pipe(Effect.provideService(precondition.tag, proof))
300
+ }).pipe(
301
+ // ONE layer, not two provisions, and it is the ANNOTATION above that makes
302
+ // that mandatory rather than tidy.
303
+ //
304
+ // TypeScript does not simplify a deferred conditional over an unresolved
305
+ // type variable, so `Exclude<Exclude<X, A>, B>` and
306
+ // `Exclude<X, A | B>` are unrelated types while `X` is still a parameter,
307
+ // and every provision here adds one level of nesting the annotation has to
308
+ // mirror EXACTLY. Both halves measured, each as a `TS2719` at the return
309
+ // annotation: two chained provisions refuse the flat
310
+ // `Exclude<PR, ReadLedger | AppendIsOwned>`, and even with this merge the
311
+ // flat `Exclude<R, Id | ReadLedger | AppendIsOwned>` is refused too,
312
+ // because the precondition's own tag is provided separately and further in.
313
+ // So `PR` is flat and `R` is one deep, and that asymmetry is the pipeline
314
+ // showing through rather than a slip.
315
+ //
316
+ // Merging says what is true anyway, which is why it is the fix rather than
317
+ // a cast: the ledger and the ownership marker have one lifetime and one
318
+ // meaning between them, and that meaning is *you are inside a bracket*.
319
+ Effect.provide(
320
+ Layer.merge(
321
+ ledger.layer,
322
+ Layer.succeed(AppendIsOwned, { by: 'transact' }),
323
+ ),
324
+ ),
325
+ )
326
+
327
+ const condition = deriveLayeredCondition(yield* ledger.hops)
328
+ if (condition === undefined) {
329
+ return yield* new NoReadsRecorded()
330
+ }
331
+
332
+ return yield* store.append(events, condition)
333
+ })