wyrd-scribe 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.
package/dist/stamp.js ADDED
@@ -0,0 +1,773 @@
1
+ /**
2
+ * `writePage` — THE STAMP PATH, TIER A (create-only). The Scribe-side half of the spec's step list.
3
+ *
4
+ * ⚠⚠ THE ORDER IS THE SECURITY PROPERTY, NOT AN IMPLEMENTATION DETAIL. Read the numbered steps
5
+ * below as a sequence of things that must not have happened yet:
6
+ *
7
+ * · EVERY REFUSAL KNOWABLE FROM THE REQUEST'S OWN SHAPE IS DECIDED BEFORE ANY SOURCE IS READ —
8
+ * the `Arc/` screen, the count caps, and every span fault. If any of them ran after, a caller
9
+ * could send a request that was ALWAYS going to refuse — an `Arc/` target, 65 sources, a
10
+ * malformed span — with a `derived_from` naming any path on the machine, and read those
11
+ * sources' existence and hashes out of the refusal's timing and reason. That turns a check into
12
+ * an EXISTENCE ORACLE, which is D8's hole arriving through the back door after D8 closed the
13
+ * front one. ⚠ The rule was written for `Arc/` and enforced only there until 2026-09-02; the
14
+ * counts and the span shapes were being decided after the whole source loop had run.
15
+ *
16
+ * ⚠⚠ THIS SAID "EVERY GUARANTEED REFUSAL" UNTIL 2026-09-08 AND THAT IS NO LONGER TRUE, so the
17
+ * claim is narrowed to what the order actually buys. The fence's `PARENT_ALIAS` is a guaranteed
18
+ * refusal — the page's parent is an alias, and no source read can change that — but it is
19
+ * knowable only from the vault's TOPOLOGY, not from the request's shape, and it fires at page
20
+ * creation in step 6, AFTER the source loop. ⚠ The oracle argument survives the narrowing: what
21
+ * a caller learns from a `PARENT_ALIAS` is a fact about a directory INSIDE the grant, which
22
+ * every read operation already discloses. The `.wyrd/` half is not affected at all — step 4's
23
+ * `loadConfig` refuses an aliased `.wyrd` before step 5 reads anything.
24
+ * · NO PAGE IS WRITTEN until every source has resolved and every limit KNOWABLE BEFORE THE PAGE
25
+ * EXISTS has passed. When a page IS created and the ledger step then REFUSES — the final size
26
+ * check at step 9, or the append after it — this invocation leaves that page with no ledger
27
+ * line: an ORPHAN, which the no-rollback ruling below leaves on disk and which the
28
+ * create-before-append clause two bullets down calls "detectable and repairable". That is what
29
+ * the draft size check at step 6 stands in front of.
30
+ *
31
+ * ⚠ THE FIRST SENTENCE READ UNCONDITIONALLY UNTIL 2026-09-08 — "a page created before the
32
+ * ledger line is known to be serialisable is a page this invocation leaves with no ledger line"
33
+ * — WHICH IS WIDER THAN THE CODE. The ordinary path creates the page before the FINAL size
34
+ * check and then appends successfully; the orphan is what a REFUSAL at either ledger step
35
+ * leaves, not what the ordering leaves. The condition is now in the sentence.
36
+ *
37
+ * ⚠ AND IT SAID "a page whose provenance can NEVER be recorded" BEFORE THAT, WHICH THE CODE
38
+ * ALSO DID NOT ESTABLISH. What this invocation leaves behind is a page with no ledger line.
39
+ * **`writePage` provides no pass that records one later, and re-invoking it on the same path
40
+ * refuses `EXISTS`** — so nothing in this package records one, which is narrower than the
41
+ * retired "a later pass can still record one" and is what the code actually says. Overclaiming
42
+ * in either direction is not a harmless flourish: the first makes the residual hole at step 9
43
+ * (`ST41`) sound impossible instead of merely narrow, the second promises a repair path this
44
+ * package does not contain.
45
+ *
46
+ * ⚠⚠ "EVERY LIMIT" IS NOT WHAT THIS ORDERING BUYS AND THE CLAUSE SAID SO UNTIL 2026-09-08. One
47
+ * limit is decided AFTER the page exists: the final ledger-line size check at step 9, over the
48
+ * CANONICAL path the fence returns from the create. It is reachable — a fold-equal parent alias
49
+ * of different byte width makes `created.rel` wider than the request the draft was sized
50
+ * against (`ST41`) — and when it fires the page is already on disk with no ledger line. That
51
+ * ORPHAN is the outcome, asserted PRESENT on purpose by `ST38` and `ST41` so a green suite is
52
+ * never read as meaning it was closed. What the ordering buys is the narrower claim, which is
53
+ * still the one worth having: no page exists until every check that CAN be decided without one
54
+ * has passed.
55
+ *
56
+ * ⚠⚠ THIS SAID "NOTHING IS WRITTEN" UNTIL 2026-09-08 AND THAT IS NO LONGER TRUE EITHER, so the
57
+ * subject is narrowed to the thing the ordering actually protects. Option C's part 3 hoists
58
+ * `loadConfig`'s exclusive create ahead of its read, so STEP 4 CAN CREATE `.wyrd/scribe.json`
59
+ * BEFORE ANY SOURCE IS READ. The distinction is worth the words: the config is this server's
60
+ * own bookkeeping, minted at a fixed path inside a directory the user created for it, and a
61
+ * vault that gains one has gained none of the caller's PAGE CONTENT (the `vault_id` it carries
62
+ * is whatever `newUuid` returned, and a caller supplying that option chose it). The PAGE is
63
+ * the first write of caller-supplied content, and it is still the write every check above
64
+ * stands in front of.
65
+ * Sources are read BETWEEN the two.
66
+ * · the page is created BEFORE the ledger line is appended, and that ORDER IS ALSO DELIBERATE.
67
+ * The reverse records provenance for a page that may never exist, which is a FALSE PROVENANCE
68
+ * CLAIM — D4 names that as the failure that matters, worse than breakage, because it looks
69
+ * healthy. A page with a missing ledger line is DETECTABLE — the page is on disk and the ledger
70
+ * has no line naming it, which a reader can see — and repairable BY SOMETHING OUTSIDE THIS
71
+ * PACKAGE; a ledger line with no page is a lie in the permanent record, and nothing detects it.
72
+ * ⚠ "repairable" ALONE READ AS A PROMISE THIS PACKAGE KEEPS, WHICH IT DOES NOT: `writePage` has
73
+ * no repair pass and refuses `EXISTS` on the orphan's own path. What the ordering buys is that
74
+ * the failure is the visible kind rather than the invisible kind.
75
+ *
76
+ * ⚠⚠ AND THERE IS NO ROLLBACK, WHICH IS A RULING RATHER THAN AN OMISSION. If the append fails after
77
+ * the page was created, the page STAYS and the refusal says so (`PAGE_WRITTEN_LEDGER_FAILED`,
78
+ * carrying the fence's `Created`). Two reasons, and the second is the load-bearing one: the fence
79
+ * has no delete primitive at all in tier A, and deleting BY PATH would reopen the TOCTOU window the
80
+ * fence's `wx` create exists to close — between the create and the delete, the name can become
81
+ * something else. A caller told exactly what happened can decide; a caller handed a silent cleanup
82
+ * that deleted the wrong object cannot.
83
+ */
84
+ import { randomUUID } from 'node:crypto';
85
+ import { loadConfig } from './config.js';
86
+ import { project, stamp as stampFrontmatter } from './frontmatter.js';
87
+ import { checkCounts, checkLineSize, checkPagePath, checkRequestCounts, contentHash, hashText, identity, lineageSpan, LINEAGE_SCHEMA, serialiseRecord } from './lineage.js';
88
+ import { scribeRefuse } from './refusal.js';
89
+ import { readSource, SourceCache, toLedgerPath } from './source.js';
90
+ import { resolveSpan, spanShapeFault } from './span.js';
91
+ /**
92
+ * ⚠⚠ THE SNAPSHOT — THE VALIDATED REQUEST, HELD AS DATA THIS MODULE BUILT RATHER THAN AS THE
93
+ * CALLER'S OBJECT.
94
+ *
95
+ * `preflight` used to return the caller's own object, narrowed. That is a different thing from
96
+ * validating it: `writePage` then read `content`, `derivedFrom` and every span back off the SAME
97
+ * object across two `await`s, so a getter, a Proxy, or a plain reassignment from an injected hook
98
+ * could serve one value to the validation and another to the write. **The validated thing and the
99
+ * used thing were different reads of a mutable object** — the 09-03 close-review's OWED row,
100
+ * `issuelog.md`: *request members are re-read through accessors after validation*.
101
+ *
102
+ * So every member is COPIED OUT at validation time and this module's own code does not read the
103
+ * caller's object again. `path` and `content` are strings, which are values; `derivedFrom` is rebuilt entry by entry into
104
+ * frozen plain objects, and each SPAN is rebuilt from the members `spanShapeFault` already read —
105
+ * see `snapshotSpan`. What holds the claim up is not this sentence: `ST42` counts the reads of
106
+ * three caller-supplied getters and asserts each fired EXACTLY ONCE, and `M25`/`M26` are the
107
+ * pass-through shapes it kills.
108
+ *
109
+ * ⚠ WHAT THE COPY DOES NOT REACH, stated because a snapshot invites the wider reading: `source` and
110
+ * `quote` are strings and `offset`/`length` are numbers, all of them already type-checked, so there
111
+ * is no object of the caller's left inside the snapshot for a later read to find. It says nothing
112
+ * about the SOURCE BYTES, which arrive from the fence at step 5, or about the vault changing under
113
+ * the invocation — three concurrent-writer races stay open and named in the fence. And it says
114
+ * nothing about the OPTIONS: `now`, `newUuid`, `newEventId`, `gate`, `appender` and `cache` are the
115
+ * caller's, run downstream, may close over the caller's request, and return what the caller chose.
116
+ * The snapshot contains what THIS MODULE derives from the request; it does not contain the caller.
117
+ *
118
+ * ⚠⚠ A CLASS, NOT AN INTERFACE, AND THAT IS THE DISCRIMINATOR RATHER THAN A STYLE CHOICE. The old
119
+ * split asked `isRefusalLike(checked)` — `value.ok === false` — which reads INHERITED properties, so
120
+ * a request whose PROTOTYPE carried `{ ok: false }` was admitted as a refusal and handed back to the
121
+ * caller as one. `instanceof` moves that question onto a prototype this module owns, does not
122
+ * export, and never hands to a caller — so no value a caller supplies answers it.
123
+ *
124
+ * ⚠ THE TWO GUARDS ARE NOT INDEPENDENT, AND THAT WAS MEASURED. `M27` was first written as the
125
+ * discrimination alone and SURVIVED: once the snapshot is in place, a `Validated` carries no `ok`
126
+ * on itself or on its prototype, so the old `.ok === false` read cannot be made to answer true
127
+ * either. The snapshot subsumes the forgery. What `instanceof` buys is the half that does not
128
+ * depend on the snapshot staying deep — see the note in `test/arms.mjs`.
129
+ *
130
+ * ⚠ CONSTRUCTED ONLY INSIDE `preflight`. The class is not exported, so a `Validated` in hand is
131
+ * proof that steps 1-3 ran — the same argument the `preConfigRefuse` local makes on the refusal
132
+ * side, applied to the success side. ⚠ Unlike that one this is not a cast: no expression OUTSIDE
133
+ * this module produces a `Validated`, because neither the class nor its prototype leaves it.
134
+ * Inside the module `Object.create(Validated.prototype)` would, and nothing here does — so the
135
+ * escape hatch the brand has is narrower here, not absent. (The class has no private nominal
136
+ * member, so a structurally matching object can carry the static TYPE; it does not pass
137
+ * `instanceof`, which is the check `writePage` actually asks.)
138
+ */
139
+ class Validated {
140
+ path;
141
+ content;
142
+ derivedFrom;
143
+ constructor(path, content, derivedFrom) {
144
+ this.path = path;
145
+ this.content = content;
146
+ this.derivedFrom = derivedFrom;
147
+ Object.freeze(this);
148
+ }
149
+ }
150
+ /**
151
+ * One span, rebuilt from the members the shape check ALREADY READ.
152
+ *
153
+ * ⚠⚠ REBUILT RATHER THAN COPIED, AND THE DIFFERENCE IS THE WHOLE POINT. `spanShapeFault` returns
154
+ * `SpanMembers` — the values it read, once, under `span.ts`'s read-once rule — precisely so a
155
+ * caller-supplied accessor fires exactly one time. Passing the caller's span object on to
156
+ * `resolveSpan` at step 5 threw that away: the resolver calls `spanShapeFault` again, so the
157
+ * accessor fired a SECOND time, after an `await`, and could serve a different value to the resolver
158
+ * than to the validation. Reconstructing the span from the members read here means step 5 re-reads a
159
+ * plain frozen object of this module's own making.
160
+ *
161
+ * ⚠ THE SHAPE IS THE ONE `span.ts` DOCUMENTS, spelled from `hasOffset`/`hasQuote` rather than by
162
+ * copying keys: `{ offset, length }`, `{ quote }`, or all three. Any other combination was already
163
+ * refused by `spanShapeFault` before control reaches here, so there is no fourth case to spell.
164
+ */
165
+ function snapshotSpan(members) {
166
+ if (members.hasOffset && members.hasQuote) {
167
+ return Object.freeze({
168
+ offset: members.offsetValue,
169
+ length: members.lengthValue,
170
+ quote: members.quoteValue
171
+ });
172
+ }
173
+ if (members.hasOffset) {
174
+ return Object.freeze({
175
+ offset: members.offsetValue,
176
+ length: members.lengthValue
177
+ });
178
+ }
179
+ return Object.freeze({ quote: members.quoteValue });
180
+ }
181
+ const REQUEST_KEYS = new Set(['path', 'content', 'derivedFrom']);
182
+ const DERIVED_KEYS = new Set(['source', 'spans']);
183
+ /**
184
+ * ⚠ THIS ONE IS PRIVATE ON PURPOSE, AND IT IS NOT THE COPY THAT WAS REMOVED FROM `config.ts` AND
185
+ * `source.ts`. Those two guarded values that really were `FenceRefusal`, so they now import the
186
+ * fence's own `isRefusal` (spec D8: the fence is imported, never reimplemented). The body here is
187
+ * byte-identical to it and the NARROWING IS NOT: this path guards unions that are only partly the
188
+ * fence's — `ScribeRefusal | FenceRefusal` at the config and source steps, `FenceRefusal` at the
189
+ * create and append steps — so it narrows to the structural `{ ok: false }` that all of them share
190
+ * and each caller casts to the union it actually holds.
191
+ *
192
+ * ⚠ Those casts are the honest cost of it, and they are what to attack if this is ever revisited:
193
+ * a guard that every call site has to correct is doing less work than its signature suggests. The
194
+ * fix is a discriminated union over the refusal kinds, not folding this into the fence's guard —
195
+ * that would narrow four call sites to a type two of them do not hold.
196
+ */
197
+ function isRefusalLike(value) {
198
+ return typeof value === 'object' && value !== null && value.ok === false;
199
+ }
200
+ /**
201
+ * ⚠⚠ WHICH SIDE A REFUSAL CAME FROM, DECIDED ON `resolvedPath` RATHER THAN ON THE `reason` WORD.
202
+ * The two steps that can return either kind — the config load and the source loop — need the
203
+ * answer to know whether to add `config_created`: a Scribe refusal gets it, a fence pass-through
204
+ * is the documented D8 exclusion and gets nothing.
205
+ *
206
+ * ⚠ `resolvedPath` IS THE DISCRIMINATOR BECAUSE THE FENCE DECLARES IT ON EVERY REFUSAL — an empty
207
+ * string where the refusal is not a config one, never absent — and `ScribeRefusal` has no such
208
+ * field AT ALL, deliberately (`refusal.ts`: there is nothing it could fill honestly). Matching on
209
+ * `reason` instead would mean this module holding a second copy of the fence's closed reason union,
210
+ * which is the drift D8 forbids and which would silently misfile every reason added after today.
211
+ */
212
+ function isScribeRefusal(value) {
213
+ return !('resolvedPath' in value);
214
+ }
215
+ /**
216
+ * ⚠ OWN KEYS, MIRRORING `span.ts`, AND FOR THE SAME REASON RATHER THAN FOR CONSISTENCY. An unknown
217
+ * key read leniently is a request the caller did not make succeeding as one they did: `derivedFrom`
218
+ * misspelled is a page written with NO provenance at all, silently, which is the product's whole
219
+ * value quietly absent. `in` would also walk the prototype chain and read an inherited key as a
220
+ * supplied one.
221
+ */
222
+ function ownKeysOnly(value, allowed) {
223
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
224
+ return false;
225
+ const own = Reflect.ownKeys(value);
226
+ for (const key of own) {
227
+ if (typeof key !== 'string' || !allowed.has(key))
228
+ return false;
229
+ }
230
+ // ⚠⚠ EXACTLY THE ALLOWED SET, NOT A SUBSET OF IT — and the subset version was a live defect
231
+ // measured by `ST19`. Screening only for UNKNOWN own keys lets a MISSING one fall through to the
232
+ // read below, which then walks the prototype chain and finds an INHERITED value: an object with
233
+ // `path` and `content` of its own and `derivedFrom` on its prototype passed the screen, read the
234
+ // inherited array, and wrote a page carrying provenance the caller never supplied. Requiring
235
+ // every member to be an OWN key is what makes the own-key discipline bind the READ as well as
236
+ // the decision — the same two-halves failure `span.ts` records at its own `view` read.
237
+ for (const key of allowed) {
238
+ if (!Object.prototype.hasOwnProperty.call(value, key))
239
+ return false;
240
+ }
241
+ return true;
242
+ }
243
+ /**
244
+ * ⚠⚠ THE `Arc/` SCREEN IS LEXICAL AND RUNS BEFORE THE FENCE RESOLVES ANYTHING, which is the only
245
+ * ordering that keeps it from being an oracle — see the header. The cost of being lexical is real
246
+ * and is stated rather than hidden: a path reaching `Arc/` THROUGH a link whose name is not `Arc`
247
+ * is not caught here. That residual is bounded by the fence, which refuses anything resolving
248
+ * outside the grant, and by the fact that `Arc/` immutability is doctrine about an agent's
249
+ * behaviour rather than a containment boundary — the spec is explicit that it is doctrine (D7),
250
+ * and the app itself is a neutral file tool.
251
+ *
252
+ * ⚠ CASE-INSENSITIVE, because this host's filesystem is. `arc/x.md` and `Arc/x.md` name the same
253
+ * file on Windows, so a case-sensitive screen would be trivially bypassable by typing a lowercase
254
+ * `a` — a rule that only stops the polite half of the callers.
255
+ *
256
+ * ⚠ SEPARATORS FOLDED FIRST. `Arc\x.md` is the same path to Windows and must not slip past a check
257
+ * written against `/`.
258
+ */
259
+ function targetsArc(request) {
260
+ const folded = request.split('\\').join('/').replace(/^\/+/, '');
261
+ // ⚠ DOT SEGMENTS FOLDED TOO. `Mage/../Arc/x.md` and `./Arc/x.md` name `Arc/x.md` to the fence,
262
+ // which normalises before it resolves — so a screen that reads the caller's spelling admits
263
+ // exactly the spelling a caller who knows about the screen would choose. Found by the close-side
264
+ // review on 2026-09-03, after the arms had only ever spelled the path straight.
265
+ const segments = [];
266
+ for (const part of folded.split('/')) {
267
+ if (part === '' || part === '.')
268
+ continue;
269
+ if (part === '..') {
270
+ segments.pop();
271
+ continue;
272
+ }
273
+ segments.push(part);
274
+ }
275
+ return /^arc$/i.test(segments[0] ?? '');
276
+ }
277
+ /**
278
+ * ⚠⚠ STEPS 1-3, LIFTED OUT SO NO NAMED WAY TO BUILD THE PRE-CONFIG SHAPE EXISTS OUTSIDE HERE. Every
279
+ * refusal knowable from the request's own shape is decided in here — the strict key screen, the
280
+ * `Arc/` screen, the count caps, the page-path bound and every span SHAPE fault — and the function
281
+ * returns either one of those refusals or a validated SNAPSHOT for the post-config half to use.
282
+ *
283
+ * ⚠ THIS READ "SO THE PRE-CONFIG SHAPE CANNOT BE BUILT ANYWHERE ELSE" UNTIL 2026-09-08, AND THE
284
+ * CODE ESTABLISHES NO SUCH THING. `PRE_CONFIG` is erased at runtime and the brand is applied by a
285
+ * cast, so `refusal as PreConfigRefusal` written anywhere in this module builds one — the same
286
+ * escape hatch every branded type in TypeScript has. What scope buys is that no NAMED site offers
287
+ * it: the honest reading of a post-config `preConfigRefuse(...)` is a compile error, and a
288
+ * deliberate cast is a thing a reviewer can see. That is smaller than unbuildable and is worth
289
+ * having; overclaiming it is what makes the next reader stop looking for the cast.
290
+ *
291
+ * ⚠⚠ THE SCOPE IS THE POINT, AND THE BRAND ALONE DID NOT BUY IT. `PRE_CONFIG` is erased at
292
+ * runtime, so while `preConfigRefuse` was a module-level function a post-config site could still
293
+ * CALL it: the value it returns carries no `config_created` at all, and the brand made TypeScript
294
+ * accept the return anyway — a shape the type system blesses and the caller cannot use. Making the
295
+ * helper a LOCAL of this function is what closes that, because a name out of scope is not a
296
+ * discipline, it is a compile error.
297
+ *
298
+ * ⚠ MEASURED BOTH WAYS ON 2026-09-08, over the same one-line edit: the post-config
299
+ * `if (counts) return stampRefuse(counts, configCreated);` at step 5 rewritten to call
300
+ * `preConfigRefuse(counts)` instead.
301
+ *
302
+ * · With a module-level copy of the helper hoisted back into scope, that call COMPILES CLEAN.
303
+ * `tsc --noEmit` says nothing. This is the defect the brand does not reach.
304
+ * · With the helper scoped here, the same call fails:
305
+ *
306
+ * src/stamp.ts(591,24): error TS2304: Cannot find name 'preConfigRefuse'.
307
+ *
308
+ * The brand still earns its place — it is what rejects an UNBRANDED bare refusal returned from step
309
+ * 4 down (see `WritePageResult`). Scope is what rejects a BRANDED one. Neither check subsumes the
310
+ * other, which is why both are here.
311
+ *
312
+ * ⚠⚠ THE SUCCESS RETURN IS A SNAPSHOT, NOT THE CALLER'S OBJECT, AND THAT SENTENCE WAS FALSE OF THE
313
+ * CODE UNTIL 2026-09-08. It read: "the validated request is the success return rather than `void`,
314
+ * so the post-config half reads `path`, `content` and `derivedFrom` off a value TypeScript already
315
+ * knows is well-formed." TypeScript knew the SHAPE; it did not know the value would still be that
316
+ * shape after the next `await`, because the value returned WAS the caller's own object. `writePage`
317
+ * then read `derivedFrom` and `content` back off it, and a getter or a Proxy could answer
318
+ * differently the second time. See `Validated` for the copy that closes it.
319
+ */
320
+ function preflight(request) {
321
+ /**
322
+ * ⚠ THE ONLY NAMED WAY TO BUILD A `PreConfigRefusal`, AND IT IS A LOCAL WITH ONE JOB. The brand
323
+ * has no runtime existence, so this adds nothing to the value; what it adds is a NAMED SITE that
324
+ * EXISTS ONLY HERE. Every step 1-3 refusal routes through it, which makes "was this decided
325
+ * before the config load?" a question answered by scope rather than by reading control flow —
326
+ * and a post-config site that tries the same call does not fail review, it fails to compile.
327
+ *
328
+ * ⚠ IT SAID "THE ONLY WAY" UNTIL 2026-09-08. The body is a cast, so the shape it produces can be
329
+ * produced by writing the same cast — see the note above the function for what scope does and
330
+ * does not buy.
331
+ */
332
+ function preConfigRefuse(refusal) {
333
+ return refusal;
334
+ }
335
+ /**
336
+ * ---- 1. the request shape, strictly, AND EVERY MEMBER READ EXACTLY ONCE ------------------
337
+ *
338
+ * ⚠⚠ THE READ IS THE FIRST THING, AND THE VALIDATION RUNS OVER WHAT WAS READ. The previous
339
+ * shape read `view.path` three times (the type check, the `Arc/` screen, the page-path bound)
340
+ * and `view.derivedFrom` four, so even INSIDE this function a caller-supplied accessor could
341
+ * answer differently each time: a `Mage/` path for the screen and an `Arc/` one for the bound.
342
+ * Reading each member once into a local and validating the LOCAL is `span.ts`'s read-once rule
343
+ * (`SpanMembers`, and the `M1` defect behind it) applied one level up. Every check below reads
344
+ * a `const` this function owns; nothing reaches back through `request` after this block.
345
+ */
346
+ if (!ownKeysOnly(request, REQUEST_KEYS)) {
347
+ return preConfigRefuse(scribeRefuse('BAD_INPUT', 'the request carries an unknown or missing key'));
348
+ }
349
+ const view = request;
350
+ const rawPath = view.path;
351
+ const rawContent = view.content;
352
+ const rawDerivedFrom = view.derivedFrom;
353
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
354
+ return preConfigRefuse(scribeRefuse('BAD_INPUT', 'path must be a non-empty string'));
355
+ }
356
+ if (typeof rawContent !== 'string') {
357
+ return preConfigRefuse(scribeRefuse('BAD_INPUT', 'content must be a string'));
358
+ }
359
+ if (!Array.isArray(rawDerivedFrom)) {
360
+ return preConfigRefuse(scribeRefuse('DERIVED_FROM_INVALID', 'derivedFrom must be an array'));
361
+ }
362
+ /**
363
+ * ⚠ THE ENTRY LIST IS COPIED OFF THE ARRAY BEFORE ANYTHING IS VALIDATED, for the reason the
364
+ * members are: an array is an object, and its indices can be accessors. `[...rawDerivedFrom]`
365
+ * reads each index once; every loop below then iterates this module's own array.
366
+ */
367
+ const entries = Object.freeze([...rawDerivedFrom]);
368
+ /** The snapshot, filled entry by entry as each is validated. Frozen plain data, all of it. */
369
+ const snapshot = [];
370
+ let requestedSpans = 0;
371
+ for (const entry of entries) {
372
+ if (!ownKeysOnly(entry, DERIVED_KEYS)) {
373
+ return preConfigRefuse(scribeRefuse('DERIVED_FROM_INVALID', 'a derived_from entry carries an unknown or missing key'));
374
+ }
375
+ const source = entry.source;
376
+ const rawSpans = entry.spans;
377
+ if (typeof source !== 'string' || source.length === 0) {
378
+ return preConfigRefuse(scribeRefuse('DERIVED_FROM_INVALID', 'a derived_from entry needs a non-empty source path'));
379
+ }
380
+ // ⚠ AN EMPTY `spans` ARRAY REFUSES. A source cited with no span is a provenance claim with
381
+ // no evidence — it records "this page came from that file" while pointing at nothing inside
382
+ // it, which is precisely the unfalsifiable claim D4's span identity exists to replace.
383
+ if (!Array.isArray(rawSpans) || rawSpans.length === 0) {
384
+ return preConfigRefuse(scribeRefuse('DERIVED_FROM_INVALID', 'a derived_from entry needs at least one span'));
385
+ }
386
+ // The same one-read-per-index rule as the entry list above, one level down.
387
+ const spans = Object.freeze([...rawSpans]);
388
+ requestedSpans += spans.length;
389
+ snapshot.push(Object.freeze({ source, spans: spans }));
390
+ }
391
+ // ---- 2. Arc/ — BEFORE any source is touched -----------------------------
392
+ // ⚠⚠ THIS DETAIL WAS REWRITTEN TWICE ON 2026-09-08 AND THE OLD SENTENCES ARE NOT REPEATED HERE,
393
+ // because a refusal detail is RETURNED API TEXT and each one claimed more than this server can
394
+ // keep. The first asserted the negative universally and across every tier, speaking for tiers
395
+ // that do not exist yet and for code this package does not contain. The second narrowed it to
396
+ // "this server (tier A) does not write into it" — still CATEGORICAL, and still false of what
397
+ // the code does: this screen is LEXICAL (see `targetsArc`), and the fence's `PARENT_ALIAS`
398
+ // catches the statically resolvable aliases, while three concurrent-writer races stay open and
399
+ // named in the fence. A path reaching `Arc/` through a race is not refused by anything here.
400
+ //
401
+ // ⚠ SO THE SENTENCE DESCRIBES THE ACT RATHER THAN THE OUTCOME: it refuses what is ADDRESSED
402
+ // into `Arc/` — spelled, or statically aliased below the grant root — which is exactly what it
403
+ // does, and it makes no claim about what cannot arrive there by other means. A refusal detail
404
+ // is the hardest place to walk a guarantee back from, because the caller reads it as the
405
+ // contract.
406
+ if (targetsArc(rawPath)) {
407
+ return preConfigRefuse(scribeRefuse('ARC_IMMUTABLE', 'Arc/ is the provenance layer; this server refuses writes addressed into it'));
408
+ }
409
+ /**
410
+ * ---- 3. EVERY REFUSAL THIS REQUEST HAS ALREADY EARNED, decided before a source is read ------
411
+ *
412
+ * ⚠⚠ THE SAME ORACLE ARGUMENT AS THE `Arc/` SCREEN ABOVE, AND IT WAS ONLY HALF-APPLIED. The
413
+ * header says the doctrine check runs first so a guaranteed refusal cannot report a source's
414
+ * existence through its timing and reason. Two other guaranteed refusals were running AFTER the
415
+ * whole source loop:
416
+ *
417
+ * · THE COUNT CAPS. `checkCounts` ran on the assembled `sources`, so a request with 65
418
+ * entries read all 65 — every existence probe, every hash, every window loop — and then
419
+ * refused for a reason knowable from `derivedFrom.length` alone. An existing in-grant
420
+ * source and a missing one produced different reasons and visibly different timings for a
421
+ * request that could never have succeeded.
422
+ * · A MALFORMED SPAN. `{ }` is refused by `resolveSpan` on shape alone, but the shape was not
423
+ * consulted until after that entry's source had been read.
424
+ *
425
+ * So both are decided here, before `loadConfig` and before any read. What CANNOT move is
426
+ * everything that needs the bytes — whether a range fits, whether a quote is present, unique or
427
+ * aligned — and `spanShapeFault` draws exactly that line, as `resolveSpan`'s own first step
428
+ * rather than as a second opinion about it.
429
+ *
430
+ * ⚠ THE COUNTS ARE CHECKED OVER THE REQUEST, NOT OVER THE RESOLVED SOURCES, and the two agree
431
+ * because the loop below pushes exactly one `LineageSource` per entry and one span per span.
432
+ * `checkCounts` is re-run there over the real records, unchanged — it is cheap, it is the check
433
+ * the limits are actually about, and a bound proved twice over the same numbers costs nothing.
434
+ *
435
+ * ⚠ `requestedSpans` AND THE ENTRY COUNT ARE BOTH TAKEN FROM THE SNAPSHOT, not from a second
436
+ * walk of the caller's arrays. The old shape re-read `view.derivedFrom` twice more here, so a
437
+ * request could present 2 entries to the cap and 65 to the loop.
438
+ */
439
+ const counted = checkRequestCounts(snapshot.length, requestedSpans);
440
+ if (counted)
441
+ return preConfigRefuse(counted);
442
+ /**
443
+ * ⚠ THE PAGE PATH IS BOUNDED HERE FOR THE REASON THE COUNTS ARE — it is knowable from the
444
+ * request's own shape, so a guaranteed refusal is decided before any source is read, existence
445
+ * probed or timing leaked. It is folded as the ledger will spell it, because that is the string
446
+ * whose bytes land in the line; bounding the raw request would bound a different value than the
447
+ * one that consumes the budget.
448
+ */
449
+ const pathSize = checkPagePath(toLedgerPath(rawPath));
450
+ if (pathSize)
451
+ return preConfigRefuse(pathSize);
452
+ /**
453
+ * ⚠⚠ THE SPAN SHAPE IS CHECKED AND THE SPAN IS REBUILT IN THE SAME PASS, which is what carries
454
+ * `span.ts`'s read-once rule across the `await` that follows. `spanShapeFault` reads each
455
+ * supplied member exactly once and HANDS THE VALUES BACK for precisely this reason; passing the
456
+ * caller's span object on to step 5's `resolveSpan` made the resolver call `spanShapeFault`
457
+ * again, so an accessor fired a second time — after the config load and after the source read —
458
+ * and could serve the resolver a different value than the one validated here. `snapshotSpan`
459
+ * rebuilds the span from the members already read, so step 5 re-reads plain frozen data.
460
+ */
461
+ for (let index = 0; index < snapshot.length; index += 1) {
462
+ const entry = snapshot[index];
463
+ const spans = [];
464
+ for (const spanRequest of entry.spans) {
465
+ const shape = spanShapeFault(spanRequest);
466
+ // ⚠ THE `SPAN_*` REFUSAL IS RETURNED AS-IS HERE TOO, for the reason the source loop
467
+ // gives below: `span.ts` made `reason` and `where` name the member at fault, and a
468
+ // caller must not get a different diagnostic depending on WHEN the fault was noticed.
469
+ if (shape.ok === false)
470
+ return preConfigRefuse(shape);
471
+ spans.push(snapshotSpan(shape));
472
+ }
473
+ snapshot[index] = Object.freeze({ source: entry.source, spans: Object.freeze(spans) });
474
+ }
475
+ return new Validated(rawPath, rawContent, Object.freeze(snapshot));
476
+ }
477
+ /**
478
+ * ⚠⚠ THE OUTER HALF IS THE **ONLY** SCOPE IN WHICH THIS MODULE HOLDS THE CALLER'S REQUEST OBJECT,
479
+ * and that is the containment rather than a tidy split. (The caller's own option hooks may close
480
+ * over that object and run downstream; they are the caller's, and outside what the split
481
+ * contains.) Steps 4-9 live in `stampValidated` below, which takes a
482
+ * `Validated` snapshot and has NO `request` parameter — so a downstream read of the caller's object
483
+ * is not a discipline anyone has to remember, it is `TS2304: Cannot find name 'request'`. The same
484
+ * argument the `preConfigRefuse` local makes about building a pre-config refusal: a name out of
485
+ * scope is a compile error.
486
+ *
487
+ * ⚠⚠ THE DISCRIMINATION IS `instanceof`, NOT `ok === false`, AND THE OLD TEST WAS A LIVE DEFECT.
488
+ * `isRefusalLike(checked)` read `.ok` through the prototype chain, so a request whose PROTOTYPE
489
+ * carried `{ ok: false }` came back from `preflight` validated, was read HERE as a refusal, and was
490
+ * returned to the caller as one — a refusal the caller minted, wearing this module's name. Asking
491
+ * `checked instanceof Validated` puts the answer on a prototype this module owns and never exports,
492
+ * which a caller cannot supply through inheritance or any other means. `isRefusalLike` stays where
493
+ * it belongs, guarding the fence and Scribe results below — values returned by the `gate` and the
494
+ * `appender`, which are injected dependencies. Reading `.ok` off those is trust in the dependency
495
+ * the caller chose to inject, not a defence against the request; it was never meant as one.
496
+ */
497
+ export async function writePage(request, options) {
498
+ // ---- 1-3. every refusal knowable from the request alone, before any source or config ----
499
+ // ⚠ `preflight` OWNS THESE STEPS AND OWNS THE ONLY `preConfigRefuse` IN THE MODULE. See its
500
+ // note for why the helper is a local of it rather than a module-level function.
501
+ const checked = preflight(request);
502
+ if (!(checked instanceof Validated))
503
+ return checked;
504
+ return stampValidated(checked, options);
505
+ }
506
+ async function stampValidated(checked, options) {
507
+ const { gate, appender, version } = options;
508
+ const now = options.now ?? (() => new Date());
509
+ const newUuid = options.newUuid ?? randomUUID;
510
+ const newEventId = options.newEventId ?? randomUUID;
511
+ const cache = options.cache ?? new SourceCache();
512
+ // ---- 4. the vault's config. THE CONFIG MAY BE CREATED HERE. --------------
513
+ // ⚠⚠ THIS STEP CAN WRITE, AND IT IS THE ONLY WRITE THAT PRECEDES THE SOURCE LOOP. Part 3 hoists
514
+ // `loadConfig`'s exclusive create ahead of its read, so an uninitialised vault gains
515
+ // `.wyrd/scribe.json` here — before a single source has been read. What it does NOT create is
516
+ // anything of the caller's; see the header for why that distinction carries the ordering claim.
517
+ // ⚠ A SCRIBE-SHAPED REFUSAL FROM THE LOAD ITSELF CARRIES `false`, AND THAT IS NOT AN ASSUMPTION:
518
+ // `loadConfig` returns a Scribe refusal only where the create did not succeed — the `.wyrd/`
519
+ // probe, or an existing config that is unreadable or fails validation. In every one of those the
520
+ // create either never ran or refused, so nothing was minted.
521
+ // ⚠⚠ AND THE SENTENCE ABOVE WAS FALSE OF THE CODE UNTIL 2026-09-08, WHICH IS THE FINDING. The
522
+ // refusal was returned BARE here, so the `SCRIBE_NOT_INITIALISED` and `SCRIBE_CONFIG_*` shapes
523
+ // reached the caller with no `config_created` at all while the documentation two screens up
524
+ // promised one. A comment asserting a field the return site does not set is the class this round
525
+ // closes: the fix is at the return, not in the sentence.
526
+ const loaded = await loadConfig(gate, newUuid);
527
+ if (isRefusalLike(loaded)) {
528
+ // ⚠ THE FENCE'S OWN REFUSALS PASS THROUGH UNCHANGED (D8) and are the ONE documented
529
+ // exclusion; a Scribe refusal is re-frozen with the flag. `isScribeRefusal` reads the
530
+ // fence's `resolvedPath`, which a Scribe refusal never carries — see `refusal.ts`.
531
+ if (isScribeRefusal(loaded))
532
+ return stampRefuse(loaded, false);
533
+ return loaded;
534
+ }
535
+ const config = loaded.config;
536
+ // ⚠ THE FLAG TRAVELS ON EVERY SCRIBE-SHAPED OUTCOME FROM HERE DOWN, REFUSALS INCLUDED. A caller
537
+ // told only "refused" cannot tell whether their vault was left exactly as they found it.
538
+ const configCreated = loaded.created;
539
+ // ---- 5. every source and every span. No PAGE has been written yet. -------
540
+ const sources = [];
541
+ for (const entry of checked.derivedFrom) {
542
+ const source = await readSource(gate, entry.source, cache);
543
+ // ⚠ THE SAME SPLIT AS THE CONFIG LOAD ABOVE, AND `SOURCE_CHANGED_DURING_READ` IS WHY IT
544
+ // MATTERS HERE. That refusal is the Scribe's own and it fires AFTER step 4 may have minted
545
+ // the config, so returning it bare — which this site did until 2026-09-08 — told a caller
546
+ // nothing about a real, persistent change to their vault. The fence's refusals in the same
547
+ // position stay unwrapped under D8.
548
+ if (isRefusalLike(source)) {
549
+ if (isScribeRefusal(source))
550
+ return stampRefuse(source, configCreated);
551
+ return source;
552
+ }
553
+ const spans = [];
554
+ for (const spanRequest of entry.spans) {
555
+ const resolved = resolveSpan(source.bytes, spanRequest);
556
+ // ⚠ A `SPAN_*` REFUSAL KEEPS ITS OWN `reason` AND `where`, not translated. `span.ts`
557
+ // spent four gate rounds making them say exactly which member is at fault; re-wrapping
558
+ // them into one Scribe reason would discard the diagnostic that work produced.
559
+ // ⚠⚠ IT GAINS `config_created` AND NOTHING ELSE, ADDED 2026-09-08. This refusal is
560
+ // Scribe-originated and fires at step 5, after step 4 may have minted the config — so
561
+ // the rule at `WritePageResult` binds it exactly as it binds every other own shape, and
562
+ // the previous exemption (see `StampRefusal`) had the module boundary in the wrong place.
563
+ if (!resolved.ok)
564
+ return stampSpanRefuse(resolved, configCreated);
565
+ spans.push(lineageSpan(resolved));
566
+ }
567
+ sources.push(Object.freeze({
568
+ identity: identity(config.vault_id, source.rel),
569
+ content: contentHash(source.digest, source.size),
570
+ spans: Object.freeze(spans)
571
+ }));
572
+ }
573
+ const counts = checkCounts(sources);
574
+ if (counts)
575
+ return stampRefuse(counts, configCreated);
576
+ /**
577
+ * ---- 6. build and size-check the record. STILL NO PAGE, and the config may exist. ---------
578
+ *
579
+ * ⚠⚠ THIS HEADING SAID "Still nothing written" UNTIL 2026-09-08 AND THAT WAS THE WRONG SUBJECT.
580
+ * Step 4 above can have created `.wyrd/scribe.json` before control reaches here. What is still
581
+ * true AT THIS LINE, and is the property the ordering buys, is that NO PAGE exists: not one byte
582
+ * of caller-supplied content has been written yet. `config_created` is what tells the caller
583
+ * about the other half.
584
+ *
585
+ * ⚠⚠ AND THE SENTENCE THAT FOLLOWED IT — "every refusal below this line leaves the caller's own
586
+ * material untouched" — WAS FALSE, RETRACTED 2026-09-08. Two refusals below this line occur
587
+ * AFTER step 8 has created the page: the final ledger-line size check at step 9, and the append
588
+ * failure after it. Both return over a page that is already on disk (`PAGE_WRITTEN_LEDGER_FAILED`
589
+ * carries the `Created` for exactly that reason), and neither is rolled back. The true scope is
590
+ * this line down to step 8, not this line down to the end of the function.
591
+ *
592
+ * ⚠ THE TARGET'S LEDGER PATH IS THE REQUEST, FOLDED — NOT the fence's `rel`, because the fence
593
+ * has not resolved it yet and will not until the create. That is a real difference: a request
594
+ * reaching the target through an in-grant junction resolves to a canonical `rel` the record
595
+ * would otherwise not carry. So the record is REBUILT from `created.rel` after step 8, and this
596
+ * first build exists to prove the line SERIALISES within its bounds before anything is created,
597
+ * and to fix the values the final record reuses (the frontmatter projection, the event id and
598
+ * the time). Reusing it WHOLE would record the request's spelling as the identity.
599
+ *
600
+ * ⚠⚠ THE PLACEHOLDERS ARE THE WIDEST VALUES THEIR FIELDS CAN EVER HOLD, AND THE NARROW ONES
601
+ * WERE A LIVE DEFECT. `bytes: 0` is one character where a real byte count can be sixteen, so a
602
+ * draft landing within a few bytes of the ceiling PASSED this check, the page was CREATED, and
603
+ * the final check then failed the line — returning `PAGE_WRITTEN_LEDGER_FAILED` with a
604
+ * fabricated `IO_ERROR` cause for what was really `LINEAGE_LINE_TOO_LARGE`. A caller was told a
605
+ * page had been written and its provenance lost to an I/O failure, when in fact the request was
606
+ * over a documented bound and nothing should have been created at all.
607
+ *
608
+ * The digest is fixed-width at 64 hex characters, so `'0'.repeat(64)` is already exact. The
609
+ * byte count is not, so it is `Number.MAX_SAFE_INTEGER` — the widest number `JSON.stringify`
610
+ * can emit for this field, and wider than any file this process can hash. The canonical path is
611
+ * the remaining variable-width member and it is handled by the re-check below, which stays.
612
+ */
613
+ const draft = buildRecord({
614
+ version,
615
+ eventId: newEventId(),
616
+ recordedAt: now().toISOString(),
617
+ vaultId: config.vault_id,
618
+ pagePath: toLedgerPath(checked.path),
619
+ pageContent: contentHash('0'.repeat(64), Number.MAX_SAFE_INTEGER),
620
+ sources
621
+ });
622
+ const draftSize = checkLineSize(serialiseRecord(draft));
623
+ if (draftSize)
624
+ return stampRefuse(draftSize, configCreated);
625
+ // ---- 7. compose the page bytes ------------------------------------------
626
+ let pageText = checked.content;
627
+ if (config.write_frontmatter) {
628
+ const stamped = stampFrontmatter(checked.content, project(draft));
629
+ if (typeof stamped !== 'string')
630
+ return stampRefuse(stamped, configCreated);
631
+ pageText = stamped;
632
+ }
633
+ const pageBytes = Buffer.from(pageText, 'utf8');
634
+ const pageDigest = hashText(pageBytes);
635
+ // ---- 8. create the page. THIS is the first write OF CALLER-SUPPLIED CONTENT. --------------
636
+ // ⚠⚠ THIS SAID "THIS is the first write" UNTIL 2026-09-08 AND IT IS NO LONGER THE FIRST WRITE
637
+ // OF ANYTHING. Step 4 can have created the vault's own `.wyrd/scribe.json` before any source
638
+ // was read. It IS the first write of the caller's bytes, which is the claim the whole step
639
+ // ordering above is built to protect, so the sentence is narrowed to it rather than dropped.
640
+ const created = await gate.createFileInGrant(checked.path, pageBytes);
641
+ // ⚠ `EXISTS` IS TIER A'S "PATH EXISTS" RULE ARRIVING FROM THE FENCE FOR FREE, and taking it
642
+ // from there rather than pre-checking is the whole point: a pre-check is a TOCTOU window, while
643
+ // the `wx` open refuses atomically against anything that appears in between.
644
+ if (isRefusalLike(created))
645
+ return created;
646
+ // ---- 9. the ledger line, over the CANONICAL path -------------------------
647
+ // ⚠ THE SAME `event_id` AS THE DRAFT, AND THEREFORE THE SAME ONE THE PAGE CARRIES. Minting a
648
+ // second here would give one write two event identities and break the only join between the
649
+ // stamped page and its ledger line — the field would then be worse than absent, because it
650
+ // would look like a key and match nothing.
651
+ const record = buildRecord({
652
+ version,
653
+ eventId: draft.event_id,
654
+ recordedAt: draft.recorded_at,
655
+ vaultId: config.vault_id,
656
+ pagePath: toLedgerPath(created.rel),
657
+ pageContent: contentHash(pageDigest, pageBytes.length),
658
+ sources
659
+ });
660
+ const line = serialiseRecord(record);
661
+ /**
662
+ * ⚠⚠ A LIVE CHECK THAT REALLY TRIPS, AND THIS NOTE CLAIMED THE OPPOSITE UNTIL 2026-09-08. The
663
+ * retired wording called it "a defensive assertion that can no longer trip" and argued that
664
+ * because the draft's placeholders are the WIDEST values their fields can hold, "a draft that
665
+ * passed guarantees this line passes: only the canonical path can differ." **The last clause is
666
+ * what fails.** The canonical path differing is not a null case — `created.rel` can be
667
+ * materially WIDER in bytes than the request, because `_parentIsAliased` compares FOLDED and
668
+ * `toLowerCase()` maps the Kelvin sign `K` (three UTF-8 bytes) to `k` (one). So two spellings
669
+ * fold equal, are accepted as the same directory by design, and differ by two bytes per
670
+ * character. `ST41` drives exactly that and reaches this branch deterministically.
671
+ *
672
+ * The placeholder widening is still worth having — it moved the ORDINARY over-size request to
673
+ * the draft, where no page is created — but it bounds only the fields it replaced, and the
674
+ * canonical path is not one of them. `MAX_PAGE_PATH_BYTES` bounds the REQUEST's spelling, which
675
+ * is a different string from the one measured here.
676
+ *
677
+ * ⚠⚠ SO THE COST IS REAL AND IS NAMED RATHER THAN DESIGNED AWAY: by the time control reaches
678
+ * here the page EXISTS, so a refusal at this line reports `PAGE_WRITTEN_LEDGER_FAILED` over a
679
+ * page already on disk with no ledger line — an ORPHAN, which nothing rolls back (see the
680
+ * header's no-rollback ruling). `ST41` asserts the orphan page PRESENT on purpose. The ordinary
681
+ * `LINEAGE_LINE_TOO_LARGE` is still decided at the draft, before anything is created; this is
682
+ * the residual the draft cannot reach.
683
+ *
684
+ * ⚠⚠ THE CAUSE IS THE SCRIBE'S OWN REFUSAL, NOT A FABRICATED `IO_ERROR`. This site used to
685
+ * invent a fence-shaped `IO_ERROR` because `LedgerFailed.cause` was typed `FenceRefusal` and
686
+ * nothing honest fit — so a caller over a documented bound was told a filesystem error had
687
+ * lost their provenance, which is a different problem with a different repair. `checkLineSize`
688
+ * already returns the exact refusal, carrying the measured byte count and the ceiling, so it
689
+ * is passed through UNCHANGED. **The orphan page is not fixed by this and is not claimed to
690
+ * be** — the page still exists with no lineage line. What changes is that the caller is told
691
+ * why, and `MAX_PAGE_PATH_BYTES` below now bounds the field that can put them here.
692
+ */
693
+ const finalSize = checkLineSize(line);
694
+ if (finalSize)
695
+ return ledgerFailed(created, finalSize, configCreated);
696
+ const appended = await appender.appendLine(line);
697
+ if (isRefusalLike(appended))
698
+ return ledgerFailed(created, appended, configCreated);
699
+ return Object.freeze({
700
+ ok: true,
701
+ created,
702
+ record,
703
+ appended: appended.bytes,
704
+ config_created: configCreated
705
+ });
706
+ }
707
+ /**
708
+ * A Scribe-originated refusal, re-frozen with the invocation's outer summary.
709
+ *
710
+ * ⚠ THE INPUT IS NOT MUTATED. `scribeRefuse` freezes what it returns, and the checkers upstream
711
+ * (`checkCounts`, `checkLineSize`, the frontmatter stamp) hand back frozen values that other code
712
+ * may hold; a copy is the only way to add the field without reaching into someone else's object.
713
+ */
714
+ function stampRefuse(refusal, configCreated) {
715
+ return Object.freeze({
716
+ ok: false,
717
+ reason: refusal.reason,
718
+ detail: refusal.detail,
719
+ config_created: configCreated
720
+ });
721
+ }
722
+ /**
723
+ * A `SPAN_*` refusal from step 5, re-frozen with the outer summary.
724
+ *
725
+ * ⚠ THE INPUT IS NOT MUTATED, for the reason `stampRefuse` gives: `span.ts` freezes what it returns
726
+ * and the resolver's value may be held elsewhere. ⚠ AND THE COPY IS MEMBER-BY-MEMBER RATHER THAN A
727
+ * SPREAD, so this function can never widen the envelope by more than the one documented key — a
728
+ * spread would carry through anything a future `span.ts` added, silently, which is the failure the
729
+ * own-key assertions exist to catch.
730
+ */
731
+ function stampSpanRefuse(refusal, configCreated) {
732
+ return Object.freeze({
733
+ ok: false,
734
+ reason: refusal.reason,
735
+ where: refusal.where,
736
+ config_created: configCreated
737
+ });
738
+ }
739
+ function ledgerFailed(created, cause, configCreated) {
740
+ return Object.freeze({
741
+ ok: false,
742
+ reason: 'PAGE_WRITTEN_LEDGER_FAILED',
743
+ detail: 'the page was created and its lineage line was NOT appended; the page is not removed',
744
+ created,
745
+ cause,
746
+ config_created: configCreated
747
+ });
748
+ }
749
+ function buildRecord(parts) {
750
+ return Object.freeze({
751
+ schema: LINEAGE_SCHEMA,
752
+ event: 'page_written',
753
+ // ⚠ SERVER-GENERATED, AND THERE IS NO CALLER-SUPPLIED ACTOR FIELD ANYWHERE IN THIS RECORD.
754
+ // MCP over stdio supplies no authenticated caller, so an `actor` would be an unauthenticated
755
+ // assertion written permanently into a provenance ledger as if it were fact — which is worse
756
+ // than recording nothing, because a reader cannot tell the two apart. `event_id` is minted
757
+ // under the same rule and for the same reason.
758
+ event_id: parts.eventId,
759
+ recorded_at: parts.recordedAt,
760
+ writer: Object.freeze({
761
+ server: 'wyrd-scribe',
762
+ version: parts.version,
763
+ tool: 'write_page'
764
+ }),
765
+ vault: Object.freeze({ kind: 'uuid', id: parts.vaultId }),
766
+ page: Object.freeze({
767
+ identity: identity(parts.vaultId, parts.pagePath),
768
+ content: parts.pageContent
769
+ }),
770
+ sources: Object.freeze([...parts.sources])
771
+ });
772
+ }
773
+ //# sourceMappingURL=stamp.js.map