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/LICENSE +201 -0
- package/README.md +114 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +215 -0
- package/dist/config.js.map +1 -0
- package/dist/frontmatter.d.ts +87 -0
- package/dist/frontmatter.js +302 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +69 -0
- package/dist/ledger.js +64 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lineage.d.ts +195 -0
- package/dist/lineage.js +194 -0
- package/dist/lineage.js.map +1 -0
- package/dist/main.d.ts +27 -0
- package/dist/main.js +169 -0
- package/dist/main.js.map +1 -0
- package/dist/refusal.d.ts +36 -0
- package/dist/refusal.js +24 -0
- package/dist/refusal.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +145 -0
- package/dist/server.js.map +1 -0
- package/dist/source.d.ts +76 -0
- package/dist/source.js +134 -0
- package/dist/source.js.map +1 -0
- package/dist/span.d.ts +117 -0
- package/dist/span.js +242 -0
- package/dist/span.js.map +1 -0
- package/dist/stamp.d.ts +297 -0
- package/dist/stamp.js +773 -0
- package/dist/stamp.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +60 -0
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
|