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/refusal.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCRIBE'S OWN REFUSAL SHAPE, and the one rule that binds every field in it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ NEVER A RESOLVED ABSOLUTE PATH IN A `detail`. This is the fence's `META-no-outside-names`
|
|
5
|
+
* property carried across the package boundary — the fence returns `rel` and never `actual`
|
|
6
|
+
* precisely so a refusal cannot become a filesystem oracle, and a Scribe refusal that echoed a
|
|
7
|
+
* caller-supplied path would hand that oracle back one layer up. So a Scribe `detail` names a
|
|
8
|
+
* CONSTANT (`.wyrd/scribe.json`) or a BOUND (`65536 bytes`), never a request string.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ THE CALLER'S OWN REQUEST STRING IS NOT SAFE TO ECHO EITHER, and that is the non-obvious half.
|
|
11
|
+
* It looks harmless — the caller already knows what it sent — but the refusal travels to a MODEL
|
|
12
|
+
* driving the server over stdio, and echoing `C:\Users\...\secrets\x.md` back into a transcript
|
|
13
|
+
* puts an outside name in the one place the fence spent seven rounds keeping it out of. A refusal
|
|
14
|
+
* says which RULE fired; it does not repeat the request.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ FENCE REFUSALS PASS THROUGH UNCHANGED and are NOT re-wrapped in this shape. A parallel
|
|
17
|
+
* vocabulary for `ESCAPES`/`MISSING`/`EXISTS` would be a second thing to get wrong, and D8's whole
|
|
18
|
+
* argument is that there is one containment implementation and one set of words for its outcomes.
|
|
19
|
+
* `SOURCE_MISSING` in the spec's prose IS the fence's `MISSING`; it is not a distinct reason.
|
|
20
|
+
*/
|
|
21
|
+
export function scribeRefuse(reason, detail) {
|
|
22
|
+
return Object.freeze({ ok: false, reason, detail });
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=refusal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refusal.js","sourceRoot":"","sources":["../src/refusal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AA8BH,MAAM,UAAU,YAAY,CAAC,MAAoB,EAAE,MAAc;IAC7D,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,KAAc,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;AACjE,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { CallToolResult, Tool } from '@modelcontextprotocol/server';
|
|
3
|
+
export { SERVER_VERSION } from './version.js';
|
|
4
|
+
export declare const SERVER_NAME = "wyrd-scribe";
|
|
5
|
+
export type Tier = 'A' | 'B' | 'C';
|
|
6
|
+
export interface ServerContext {
|
|
7
|
+
readonly instructions: (activeTier: Tier, toolNames: readonly string[]) => string;
|
|
8
|
+
}
|
|
9
|
+
export interface ToolRegistration {
|
|
10
|
+
readonly declaration: Tool;
|
|
11
|
+
readonly call: (args: Record<string, unknown>) => Promise<CallToolResult>;
|
|
12
|
+
}
|
|
13
|
+
export type ToolLayerFactory<C extends ServerContext> = (context: C, activeTier: Tier) => readonly ToolRegistration[];
|
|
14
|
+
export interface TierLayers<C extends ServerContext> {
|
|
15
|
+
readonly A: ToolLayerFactory<C>;
|
|
16
|
+
readonly B: ToolLayerFactory<C> | null;
|
|
17
|
+
readonly C: ToolLayerFactory<C> | null;
|
|
18
|
+
}
|
|
19
|
+
export interface TierPlan<C extends ServerContext> {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly tier: Tier;
|
|
22
|
+
readonly factories: readonly ToolLayerFactory<C>[];
|
|
23
|
+
}
|
|
24
|
+
export interface StartupRefusal {
|
|
25
|
+
readonly ok: false;
|
|
26
|
+
readonly reason: 'UNRECOGNISED_TIER' | 'TIER_UNAVAILABLE';
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}
|
|
29
|
+
/** Select every cumulative layer before a grant or transport is opened. */
|
|
30
|
+
export declare function planTier<C extends ServerContext>(rawTier: string | undefined, layers: TierLayers<C>): TierPlan<C> | StartupRefusal;
|
|
31
|
+
export declare const FENCE_DISCLOSURE: string;
|
|
32
|
+
export declare function productionInstructions(root: string, tier: Tier, names: readonly string[]): string;
|
|
33
|
+
/** Build one frozen registration map; listing and dispatch both consult this object. */
|
|
34
|
+
export declare function createServer<C extends ServerContext>(plan: TierPlan<C>, context: C): Server;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Ajv } from 'ajv';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
3
|
+
import { SERVER_VERSION } from './version.js';
|
|
4
|
+
export { SERVER_VERSION } from './version.js';
|
|
5
|
+
export const SERVER_NAME = 'wyrd-scribe';
|
|
6
|
+
const ORDER = Object.freeze(['A', 'B', 'C']);
|
|
7
|
+
/** Select every cumulative layer before a grant or transport is opened. */
|
|
8
|
+
export function planTier(rawTier, layers) {
|
|
9
|
+
const selected = rawTier ?? 'A';
|
|
10
|
+
if (selected !== 'A' && selected !== 'B' && selected !== 'C') {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
ok: false,
|
|
13
|
+
reason: 'UNRECOGNISED_TIER',
|
|
14
|
+
message: `UNRECOGNISED_TIER: wyrd-scribe does not recognise tier ${JSON.stringify(selected)}`
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const tier = selected;
|
|
18
|
+
const needed = ORDER.slice(0, ORDER.indexOf(tier) + 1).map(name => layers[name]);
|
|
19
|
+
if (needed.some(factory => factory === null)) {
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: 'TIER_UNAVAILABLE',
|
|
23
|
+
message: `TIER_UNAVAILABLE: tier ${tier} is recognised but unavailable in this build`
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
ok: true,
|
|
28
|
+
tier,
|
|
29
|
+
factories: Object.freeze(needed)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export const FENCE_DISCLOSURE = [
|
|
33
|
+
'Direct normalized `Arc/` targets are refused. The fence rejects a pre-existing parent alias',
|
|
34
|
+
'when its spelled and resolved paths differ after case-folding; its documented fold-equal-alias',
|
|
35
|
+
'limit still applies. The fence rechecks root object identity before each operation that',
|
|
36
|
+
'touches the filesystem;',
|
|
37
|
+
'component/leaf/link/rename races and the interval between that recheck and opening the file',
|
|
38
|
+
'remain. Append refusals may retain a partial or complete line. See the `wyrd-fence` README',
|
|
39
|
+
'section "Security boundary and limits" and the `createFileInGrant` / `appendLineInGrant` API',
|
|
40
|
+
'documentation for the authoritative contracts.'
|
|
41
|
+
].join('\n');
|
|
42
|
+
export function productionInstructions(root, tier, names) {
|
|
43
|
+
return [
|
|
44
|
+
`wyrd-scribe is serving exactly one canonical grant: ${root}`,
|
|
45
|
+
`The active write tier is ${tier}. Exactly these tools are registered: ${names.map(name => `\`${name}\``).join(', ')}.`,
|
|
46
|
+
'',
|
|
47
|
+
FENCE_DISCLOSURE,
|
|
48
|
+
'',
|
|
49
|
+
'Every write target and every derived_from source is grant-relative and is sent through',
|
|
50
|
+
'the imported `wyrd-fence` operations. Tier A creates files exclusively; an occupied leaf refuses',
|
|
51
|
+
'EXISTS. A successful write_page creates the page and appends one lineage line, and the',
|
|
52
|
+
'vault configuration may also project lineage into frontmatter. If the ledger fails after',
|
|
53
|
+
'the page is created, the page stays in place and the refusal reports that outcome.'
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
function memberNamedBy(error) {
|
|
57
|
+
if (error.keyword === 'additionalProperties') {
|
|
58
|
+
return error.params.additionalProperty ?? null;
|
|
59
|
+
}
|
|
60
|
+
if (error.keyword === 'required') {
|
|
61
|
+
return error.params.missingProperty ?? null;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function violationPath(error) {
|
|
66
|
+
const member = memberNamedBy(error);
|
|
67
|
+
return `arguments${error.instancePath}${member === null ? '' : `/${member}`}`;
|
|
68
|
+
}
|
|
69
|
+
/** Return one actionable violation. The deepest branch error wins over `oneOf`'s wrapper error. */
|
|
70
|
+
function firstViolation(validate, args) {
|
|
71
|
+
if (validate(args))
|
|
72
|
+
return null;
|
|
73
|
+
const errors = validate.errors ?? [];
|
|
74
|
+
if (errors.length === 0)
|
|
75
|
+
return 'arguments did not satisfy inputSchema';
|
|
76
|
+
let chosen = errors[0];
|
|
77
|
+
for (const error of errors.slice(1)) {
|
|
78
|
+
if (error.instancePath.split('/').length > chosen.instancePath.split('/').length) {
|
|
79
|
+
chosen = error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const path = violationPath(chosen);
|
|
83
|
+
if (chosen.keyword === 'additionalProperties')
|
|
84
|
+
return `${path} is not allowed by inputSchema`;
|
|
85
|
+
if (chosen.keyword === 'required')
|
|
86
|
+
return `${path} is required by inputSchema`;
|
|
87
|
+
return `${path} ${chosen.message ?? `violates inputSchema keyword ${chosen.keyword}`}`;
|
|
88
|
+
}
|
|
89
|
+
/** The sole route to a registration's port: its advertised schema is compiled and enforced here. */
|
|
90
|
+
async function callValidated(registration, args) {
|
|
91
|
+
const violation = firstViolation(registration.validate, args);
|
|
92
|
+
if (violation !== null) {
|
|
93
|
+
return {
|
|
94
|
+
isError: true,
|
|
95
|
+
content: [{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: `wyrd-scribe refused invalid tool arguments: ${violation}.`
|
|
98
|
+
}]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return registration.call(args);
|
|
102
|
+
}
|
|
103
|
+
/** Build one frozen registration map; listing and dispatch both consult this object. */
|
|
104
|
+
export function createServer(plan, context) {
|
|
105
|
+
const ajv = new Ajv({ allErrors: true, strict: true });
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const factory of plan.factories) {
|
|
108
|
+
for (const registration of factory(context, plan.tier)) {
|
|
109
|
+
const name = registration.declaration.name;
|
|
110
|
+
if (entries.some(([existing]) => existing === name)) {
|
|
111
|
+
throw new Error(`duplicate tool registration: ${name}`);
|
|
112
|
+
}
|
|
113
|
+
entries.push([name, Object.freeze({
|
|
114
|
+
declaration: registration.declaration,
|
|
115
|
+
call: registration.call,
|
|
116
|
+
validate: ajv.compile(registration.declaration.inputSchema)
|
|
117
|
+
})]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const registrations = Object.freeze(Object.fromEntries(entries));
|
|
121
|
+
const names = Object.freeze(Object.keys(registrations));
|
|
122
|
+
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
123
|
+
capabilities: { tools: {} },
|
|
124
|
+
instructions: context.instructions(plan.tier, names)
|
|
125
|
+
});
|
|
126
|
+
server.setRequestHandler('tools/list', () => ({
|
|
127
|
+
tools: Object.values(registrations).map(registration => registration.declaration)
|
|
128
|
+
}));
|
|
129
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
130
|
+
const registration = registrations[request.params.name];
|
|
131
|
+
if (registration === undefined) {
|
|
132
|
+
return {
|
|
133
|
+
isError: true,
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: `wyrd-scribe has no tool named ${request.params.name}.`
|
|
137
|
+
}]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const args = (request.params.arguments ?? {});
|
|
141
|
+
return callValidated(registration, args);
|
|
142
|
+
});
|
|
143
|
+
return server;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOURCES — read and hashed THROUGH THE EXISTING GATE, with no fence change of any kind.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ D8 IS THE WHOLE REASON THIS MODULE EXISTS AT ALL, and the hole it closed is worth restating
|
|
5
|
+
* because the shape is easy to rebuild by accident. The spec's first draft fenced only the path
|
|
6
|
+
* being WRITTEN; the paths being CITED went straight to read-and-hash with no containment check.
|
|
7
|
+
* That made `write_page` an arbitrary-file-read primitive — name any path the process can reach as
|
|
8
|
+
* a "source", get back confirmation of its existence and its content hash, and because D4 stores
|
|
9
|
+
* the QUOTED TEXT, get its bytes copied permanently into the ledger. Every read here goes through
|
|
10
|
+
* `readFileInGrant`/`hashInGrant`, which are the identical code path the Reader uses.
|
|
11
|
+
*
|
|
12
|
+
* ⚠⚠ AND THE HASH IS NOT A FORMALITY — IT IS THE CHIMERA CHECK (D4). The window loop reads a
|
|
13
|
+
* source in pieces; a source edited between the first window and the last yields a buffer that
|
|
14
|
+
* existed at no instant, and span offsets over a chimera are SILENT MISLOCATION, which is the one
|
|
15
|
+
* failure this whole lane is built against. So the bytes we hold are hashed ourselves and compared
|
|
16
|
+
* against `hashInGrant`'s independent full read of the same path. A mismatch REFUSES.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ THAT COMPARISON IS A DETECTOR, NOT A LOCK. Nothing here holds the file open across the two
|
|
19
|
+
* reads — the gate opens and closes per call, deliberately, because a long-held handle is a
|
|
20
|
+
* different hazard — so a source edited and edited BACK between them passes. The claim is
|
|
21
|
+
* "detects the ordinary case", never "serialises against a concurrent writer".
|
|
22
|
+
*/
|
|
23
|
+
import type { FenceRefusal, FsGate } from 'wyrd-fence';
|
|
24
|
+
import type { ScribeRefusal } from './refusal.js';
|
|
25
|
+
export interface SourceBytes {
|
|
26
|
+
/** The grant-relative path AS THE FENCE REPORTS IT, separators already folded to `/`. */
|
|
27
|
+
readonly rel: string;
|
|
28
|
+
readonly bytes: Buffer;
|
|
29
|
+
readonly digest: string;
|
|
30
|
+
readonly size: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* ⚠ SEPARATORS ARE FOLDED HERE AND NOWHERE ELSE. The fence builds `rel` with `path.relative`,
|
|
34
|
+
* which yields BACKSLASHES on win32 — so a ledger written on this host and read on any other would
|
|
35
|
+
* carry a path no POSIX consumer can resolve, and the two hosts would disagree about whether two
|
|
36
|
+
* records name the same page. The ledger's path is a portable identity, so the fold happens at the
|
|
37
|
+
* boundary where a fence value becomes a RECORD value.
|
|
38
|
+
*/
|
|
39
|
+
export declare function toLedgerPath(rel: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* ⚠ THE FENCE'S OWN `isRefusal`, IMPORTED RATHER THAN RE-DECLARED — spec D8, and the same rule the
|
|
42
|
+
* package manifest states about `createFsGate`. This file used to carry a byte-identical private
|
|
43
|
+
* copy called `isRefusalLike`; so did `config.ts`. A local copy of a guard that decides whether a
|
|
44
|
+
* fence result is a refusal is a second definition of the fence's own contract, free to drift from
|
|
45
|
+
* it silently. ⚠ `stamp.ts` deliberately keeps a private one — read the note there before assuming
|
|
46
|
+
* it was missed.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The per-process source cache the cost model requires (spec "Cost model": a stamp is
|
|
50
|
+
* O(source bytes) once per source per process).
|
|
51
|
+
*
|
|
52
|
+
* ⚠⚠ THE KEY IS `path + size + digest`, WHICH MEANS A HIT STILL COSTS ONE FULL READ. That is not
|
|
53
|
+
* an oversight and it is the honest version of the criterion. The cost model's original key was
|
|
54
|
+
* `(path, size, mtime)`, but the gate exposes no mtime — and the only identity it DOES expose is
|
|
55
|
+
* the digest, which cannot be known without reading. So what the cache buys is skipping the WINDOW
|
|
56
|
+
* LOOP and the second buffer, not skipping I/O: `hashInGrant` streams in 64 KiB windows and
|
|
57
|
+
* retains nothing, while the window loop materialises the whole source in memory. On a hit the
|
|
58
|
+
* resident cost is one buffer instead of two and the traversal is one pass instead of two.
|
|
59
|
+
*
|
|
60
|
+
* ⚠ AC7's "reads that source's bytes once" is therefore NOT satisfied by this cache, and saying so
|
|
61
|
+
* is the point of this paragraph. Satisfying it literally needs an mtime the gate does not expose;
|
|
62
|
+
* claiming it while the digest key forces a read would be a cost claim the code cannot back.
|
|
63
|
+
*/
|
|
64
|
+
export declare class SourceCache {
|
|
65
|
+
#private;
|
|
66
|
+
get(rel: string, size: number, digest: string): SourceBytes | undefined;
|
|
67
|
+
set(entry: SourceBytes): void;
|
|
68
|
+
get size(): number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a source whole, through the gate, and prove the bytes we hold are the bytes on disk.
|
|
72
|
+
*
|
|
73
|
+
* Order matters: HASH FIRST. The hash gives the cache key, so a hit skips the window loop entirely;
|
|
74
|
+
* hashing second would mean always paying for the loop before discovering the answer was cached.
|
|
75
|
+
*/
|
|
76
|
+
export declare function readSource(gate: FsGate, request: string, cache: SourceCache): Promise<SourceBytes | ScribeRefusal | FenceRefusal>;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOURCES — read and hashed THROUGH THE EXISTING GATE, with no fence change of any kind.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ D8 IS THE WHOLE REASON THIS MODULE EXISTS AT ALL, and the hole it closed is worth restating
|
|
5
|
+
* because the shape is easy to rebuild by accident. The spec's first draft fenced only the path
|
|
6
|
+
* being WRITTEN; the paths being CITED went straight to read-and-hash with no containment check.
|
|
7
|
+
* That made `write_page` an arbitrary-file-read primitive — name any path the process can reach as
|
|
8
|
+
* a "source", get back confirmation of its existence and its content hash, and because D4 stores
|
|
9
|
+
* the QUOTED TEXT, get its bytes copied permanently into the ledger. Every read here goes through
|
|
10
|
+
* `readFileInGrant`/`hashInGrant`, which are the identical code path the Reader uses.
|
|
11
|
+
*
|
|
12
|
+
* ⚠⚠ AND THE HASH IS NOT A FORMALITY — IT IS THE CHIMERA CHECK (D4). The window loop reads a
|
|
13
|
+
* source in pieces; a source edited between the first window and the last yields a buffer that
|
|
14
|
+
* existed at no instant, and span offsets over a chimera are SILENT MISLOCATION, which is the one
|
|
15
|
+
* failure this whole lane is built against. So the bytes we hold are hashed ourselves and compared
|
|
16
|
+
* against `hashInGrant`'s independent full read of the same path. A mismatch REFUSES.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ THAT COMPARISON IS A DETECTOR, NOT A LOCK. Nothing here holds the file open across the two
|
|
19
|
+
* reads — the gate opens and closes per call, deliberately, because a long-held handle is a
|
|
20
|
+
* different hazard — so a source edited and edited BACK between them passes. The claim is
|
|
21
|
+
* "detects the ordinary case", never "serialises against a concurrent writer".
|
|
22
|
+
*/
|
|
23
|
+
import { isRefusal } from 'wyrd-fence';
|
|
24
|
+
import { hashText } from './lineage.js';
|
|
25
|
+
import { scribeRefuse } from './refusal.js';
|
|
26
|
+
/**
|
|
27
|
+
* The window the source loop asks for. The gate clamps anything above its own `MAX_LIMIT` silently,
|
|
28
|
+
* so asking for more than 1 MiB would be a request the gate quietly rewrites — a number that reads
|
|
29
|
+
* as a decision and is not one.
|
|
30
|
+
*/
|
|
31
|
+
const SOURCE_WINDOW = 1 << 20;
|
|
32
|
+
/**
|
|
33
|
+
* ⚠ SEPARATORS ARE FOLDED HERE AND NOWHERE ELSE. The fence builds `rel` with `path.relative`,
|
|
34
|
+
* which yields BACKSLASHES on win32 — so a ledger written on this host and read on any other would
|
|
35
|
+
* carry a path no POSIX consumer can resolve, and the two hosts would disagree about whether two
|
|
36
|
+
* records name the same page. The ledger's path is a portable identity, so the fold happens at the
|
|
37
|
+
* boundary where a fence value becomes a RECORD value.
|
|
38
|
+
*/
|
|
39
|
+
export function toLedgerPath(rel) {
|
|
40
|
+
return rel.split('\\').join('/');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* ⚠ THE FENCE'S OWN `isRefusal`, IMPORTED RATHER THAN RE-DECLARED — spec D8, and the same rule the
|
|
44
|
+
* package manifest states about `createFsGate`. This file used to carry a byte-identical private
|
|
45
|
+
* copy called `isRefusalLike`; so did `config.ts`. A local copy of a guard that decides whether a
|
|
46
|
+
* fence result is a refusal is a second definition of the fence's own contract, free to drift from
|
|
47
|
+
* it silently. ⚠ `stamp.ts` deliberately keeps a private one — read the note there before assuming
|
|
48
|
+
* it was missed.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The per-process source cache the cost model requires (spec "Cost model": a stamp is
|
|
52
|
+
* O(source bytes) once per source per process).
|
|
53
|
+
*
|
|
54
|
+
* ⚠⚠ THE KEY IS `path + size + digest`, WHICH MEANS A HIT STILL COSTS ONE FULL READ. That is not
|
|
55
|
+
* an oversight and it is the honest version of the criterion. The cost model's original key was
|
|
56
|
+
* `(path, size, mtime)`, but the gate exposes no mtime — and the only identity it DOES expose is
|
|
57
|
+
* the digest, which cannot be known without reading. So what the cache buys is skipping the WINDOW
|
|
58
|
+
* LOOP and the second buffer, not skipping I/O: `hashInGrant` streams in 64 KiB windows and
|
|
59
|
+
* retains nothing, while the window loop materialises the whole source in memory. On a hit the
|
|
60
|
+
* resident cost is one buffer instead of two and the traversal is one pass instead of two.
|
|
61
|
+
*
|
|
62
|
+
* ⚠ AC7's "reads that source's bytes once" is therefore NOT satisfied by this cache, and saying so
|
|
63
|
+
* is the point of this paragraph. Satisfying it literally needs an mtime the gate does not expose;
|
|
64
|
+
* claiming it while the digest key forces a read would be a cost claim the code cannot back.
|
|
65
|
+
*/
|
|
66
|
+
export class SourceCache {
|
|
67
|
+
#entries = new Map();
|
|
68
|
+
#key(rel, size, digest) {
|
|
69
|
+
return `${rel}\u0000${size}\u0000${digest}`;
|
|
70
|
+
}
|
|
71
|
+
get(rel, size, digest) {
|
|
72
|
+
return this.#entries.get(this.#key(rel, size, digest));
|
|
73
|
+
}
|
|
74
|
+
set(entry) {
|
|
75
|
+
this.#entries.set(this.#key(entry.rel, entry.size, entry.digest), entry);
|
|
76
|
+
}
|
|
77
|
+
get size() {
|
|
78
|
+
return this.#entries.size;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read a source whole, through the gate, and prove the bytes we hold are the bytes on disk.
|
|
83
|
+
*
|
|
84
|
+
* Order matters: HASH FIRST. The hash gives the cache key, so a hit skips the window loop entirely;
|
|
85
|
+
* hashing second would mean always paying for the loop before discovering the answer was cached.
|
|
86
|
+
*/
|
|
87
|
+
export async function readSource(gate, request, cache) {
|
|
88
|
+
const hashed = await gate.hashInGrant(request);
|
|
89
|
+
// ⚠ PASSED THROUGH UNDER THE FENCE'S OWN REASON — `ESCAPES`, `MISSING`, `DENIED`. The spec's
|
|
90
|
+
// `SOURCE_MISSING` IS this `MISSING`; inventing a parallel word would make two vocabularies for
|
|
91
|
+
// one outcome and force every consumer to learn both.
|
|
92
|
+
if (isRefusal(hashed))
|
|
93
|
+
return hashed;
|
|
94
|
+
const rel = toLedgerPath(hashed.rel);
|
|
95
|
+
const cached = cache.get(rel, hashed.size, hashed.digest);
|
|
96
|
+
if (cached)
|
|
97
|
+
return cached;
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let held = 0;
|
|
100
|
+
let offset = 0;
|
|
101
|
+
for (;;) {
|
|
102
|
+
const slice = await gate.readFileInGrant(request, offset, SOURCE_WINDOW);
|
|
103
|
+
if (isRefusal(slice))
|
|
104
|
+
return slice;
|
|
105
|
+
chunks.push(slice.bytes);
|
|
106
|
+
held += slice.bytes.length;
|
|
107
|
+
if (!slice.truncated)
|
|
108
|
+
break;
|
|
109
|
+
// ⚠⚠ A NON-ADVANCING WINDOW IS A REFUSAL, NEVER A RETRY. `nextOffset` can equal `offset`
|
|
110
|
+
// only if the gate kept zero bytes while still reporting more to come; looping on that is
|
|
111
|
+
// an unbounded spin inside a server, and treating it as "the source moved under us" is both
|
|
112
|
+
// true and the outcome a caller can act on.
|
|
113
|
+
if (slice.nextOffset <= offset) {
|
|
114
|
+
return scribeRefuse('SOURCE_CHANGED_DURING_READ', 'a source stopped yielding bytes before the end it reported');
|
|
115
|
+
}
|
|
116
|
+
offset = slice.nextOffset;
|
|
117
|
+
}
|
|
118
|
+
const bytes = Buffer.concat(chunks, held);
|
|
119
|
+
const ours = hashText(bytes);
|
|
120
|
+
/**
|
|
121
|
+
* ⚠⚠ BOTH HALVES ARE COMPARED, AND THE SIZE HALF IS NOT REDUNDANT. A digest comparison alone
|
|
122
|
+
* would pass if the loop somehow held a DIFFERENT number of bytes whose hash collided — which
|
|
123
|
+
* is not the realistic case — but more usefully, comparing sizes catches the ordinary shape of
|
|
124
|
+
* this failure (the file grew or shrank between the hash and the loop) with a check that cannot
|
|
125
|
+
* itself be fooled by a truncated read reporting success.
|
|
126
|
+
*/
|
|
127
|
+
if (bytes.length !== hashed.size || ours !== hashed.digest) {
|
|
128
|
+
return scribeRefuse('SOURCE_CHANGED_DURING_READ', 'a source changed between being hashed and being read; span offsets over it would be silently wrong');
|
|
129
|
+
}
|
|
130
|
+
const entry = Object.freeze({ rel, bytes, digest: hashed.digest, size: hashed.size });
|
|
131
|
+
cache.set(entry);
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source.js","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"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"}
|
package/dist/span.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B1 — THE SPAN RESOLVER. Bytes in, a located span or a refusal out.
|
|
3
|
+
*
|
|
4
|
+
* Built to `designs/2026-08-31-span-resolver-plan.md`, which carries the reasoning and four rounds
|
|
5
|
+
* of gate findings. The one rule everything here serves:
|
|
6
|
+
*
|
|
7
|
+
* ⚠⚠ NO SILENT MISLOCATION. AMBIGUITY REFUSES; IT NEVER PICKS.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ FENCE-FREE, AND THAT IS STRUCTURAL RATHER THAN POLITE. This module takes `(bytes, request)` and
|
|
10
|
+
* no path. It never opens a file, never imports the fence, and has no way to reach the filesystem —
|
|
11
|
+
* reading is the fence's job, and a resolver that opens files has become a second write path.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ THE PROPERTY THIS MODULE PROTECTS: no substituted span resolution in a refusal, and no source
|
|
14
|
+
* bytes in EITHER variant. It is NOT the fence's no-absolute-path rule — that belongs to
|
|
15
|
+
* `wyrd-fence` (`packages/wyrd-fence/src/fsgate.ts`) and is asserted there. A reviewer briefed on
|
|
16
|
+
* the fence's property is on the wrong axis here.
|
|
17
|
+
*
|
|
18
|
+
* ⚠⚠ AND THE PROPERTY IS NOT ENFORCED BY THESE TYPES. `SpanRefusal` and `ResolvedSpan` are
|
|
19
|
+
* TypeScript-only shapes: the compiler forbids no extra property at runtime, and an unchecked
|
|
20
|
+
* assignment can put a value anywhere. The enforcement is the own-key envelope in
|
|
21
|
+
* `test/span.test.js`, applied to EVERY arm — refusal and success alike. This comment is not a
|
|
22
|
+
* mechanism.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ `Object.freeze` on every return is the runtime half OF THE EXTRA-KEY RULE ONLY. It says
|
|
25
|
+
* nothing about what an ACCESSOR returns: freeze constrains the property, never the getter, so a
|
|
26
|
+
* frozen own getter can serve a correct value to the first readers and the source buffer to the
|
|
27
|
+
* next. Measured at code-gate round 2 — it passed all 29 arms. What closes that is the
|
|
28
|
+
* data-property assertion in `envelope()`, not this line.
|
|
29
|
+
*/
|
|
30
|
+
export type SpanReason = 'SPAN_INVALID_RANGE' | 'SPAN_INVALID_QUOTE' | 'SPAN_NOT_ALIGNED' | 'SPAN_NOT_FOUND' | 'SPAN_AMBIGUOUS' | 'SPAN_MISMATCH';
|
|
31
|
+
/**
|
|
32
|
+
* The member at fault — **absence included**, which is the part the narrow wording missed.
|
|
33
|
+
* `{ offset: 0 }` refuses `where: 'length'`, naming a member the caller never sent, because THERE
|
|
34
|
+
* the fault IS the absence and naming it is the informative answer. That is not in tension with
|
|
35
|
+
* the quote-only alignment path refusing `'request'`: there the fault is a property of a derived
|
|
36
|
+
* span and the absent members are not at fault at all. So `where` names the member at fault,
|
|
37
|
+
* supplied or absent — never "a supplied member."
|
|
38
|
+
*
|
|
39
|
+
* ⚠ ENUMERATED SO THE TYPE CANNOT CARRY A VALUE — and the type is not the enforcement. A frozen
|
|
40
|
+
* own accessor named `where` can serve an enumerated value to the first readers and the whole
|
|
41
|
+
* source afterwards; `envelope()`'s data-property assertion is what forbids it.
|
|
42
|
+
*/
|
|
43
|
+
export type SpanWhere = 'offset' | 'length' | 'quote' | 'request';
|
|
44
|
+
export interface ResolvedSpan {
|
|
45
|
+
readonly ok: true;
|
|
46
|
+
readonly offset: number;
|
|
47
|
+
readonly length: number;
|
|
48
|
+
readonly quote: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* ⚠ NO FREE-TEXT FIELD, AND NO SLOT FOR A RESOLUTION. There is no place in this shape for an
|
|
52
|
+
* offset, a length, a quote, a candidate list or the source bytes. A `detail: string` was removed
|
|
53
|
+
* at round 2 precisely because an unrestricted string can hold all of them.
|
|
54
|
+
*/
|
|
55
|
+
export interface SpanRefusal {
|
|
56
|
+
readonly ok: false;
|
|
57
|
+
readonly reason: SpanReason;
|
|
58
|
+
readonly where: SpanWhere;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Three shapes, all resolving to the same triple (D4 minus the hash, which is the fence's).
|
|
62
|
+
* Any other shape — a lone `offset`, an unknown key, a non-object — refuses `SPAN_INVALID_RANGE`.
|
|
63
|
+
*/
|
|
64
|
+
export type SpanRequest = {
|
|
65
|
+
readonly offset: number;
|
|
66
|
+
readonly length: number;
|
|
67
|
+
} | {
|
|
68
|
+
readonly quote: string;
|
|
69
|
+
} | {
|
|
70
|
+
readonly offset: number;
|
|
71
|
+
readonly length: number;
|
|
72
|
+
readonly quote: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Resolves a span request against the source bytes it was recorded over.
|
|
76
|
+
*
|
|
77
|
+
* ⚠ THE SOURCE IS A CALLER CONTRACT, NOT A REQUEST MEMBER. A non-Buffer source THROWS rather than
|
|
78
|
+
* refusing: `where` names a member of the request, and there is no honest value for it here. A
|
|
79
|
+
* refusal would have to lie about which member was at fault.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* EVERY REFUSAL THIS MODULE CAN REACH WITHOUT THE SOURCE BYTES, AND NOTHING ELSE.
|
|
83
|
+
*
|
|
84
|
+
* ⚠⚠ IT EXISTS SO A GUARANTEED REFUSAL CAN BE DECIDED BEFORE A SOURCE IS READ. `stamp.ts` calls it
|
|
85
|
+
* ahead of `loadConfig` and ahead of every read: a request carrying a malformed span is going to
|
|
86
|
+
* refuse whatever the bytes turn out to be, so reading the source first buys nothing and COSTS the
|
|
87
|
+
* existence-oracle property — an existing in-grant source and a missing one would otherwise produce
|
|
88
|
+
* different reasons and different timings for a request that was always going to refuse. That is
|
|
89
|
+
* the same argument the `Arc/` screen's ordering makes, applied to the other guaranteed refusal.
|
|
90
|
+
*
|
|
91
|
+
* ⚠⚠ IT IS `resolveSpan`'s OWN FIRST STEP, NOT A SECOND COPY OF THESE CHECKS. A parallel
|
|
92
|
+
* implementation is a thing that drifts: the moment one side learns a rule the other has not, a
|
|
93
|
+
* request refuses in one place and resolves in the other, and the pre-check stops being a
|
|
94
|
+
* prediction of what the resolver will do. One function, two callers.
|
|
95
|
+
*
|
|
96
|
+
* ⚠ WHAT IT DELIBERATELY DOES NOT DECIDE: range-against-length, alignment, the lossy round-trip,
|
|
97
|
+
* `SPAN_NOT_FOUND`, `SPAN_AMBIGUOUS` and `SPAN_MISMATCH`. Every one of those is a question about
|
|
98
|
+
* the bytes, and answering it here would need the source — which is precisely what must not be read
|
|
99
|
+
* yet. A `SpanMembers` from this function is NOT a prediction of success; it means only "nothing is
|
|
100
|
+
* decided yet".
|
|
101
|
+
*
|
|
102
|
+
* ⚠⚠ IT RETURNS THE MEMBERS IT READ, AND THAT IS THE READ-ONCE RULE SURVIVING THE EXTRACTION. If
|
|
103
|
+
* this function read the request's members and `resolveSpan` then read them AGAIN, a caller-supplied
|
|
104
|
+
* accessor would fire twice and could serve one value to the shape check and another to the
|
|
105
|
+
* resolver — which is precisely the defect `M1` pins, rebuilt by the refactor that was supposed to
|
|
106
|
+
* be behaviour-preserving. Handing the values back means the request object is read exactly once on
|
|
107
|
+
* every path, `stamp.ts`'s pre-check included.
|
|
108
|
+
*/
|
|
109
|
+
export interface SpanMembers {
|
|
110
|
+
readonly hasOffset: boolean;
|
|
111
|
+
readonly hasQuote: boolean;
|
|
112
|
+
readonly offsetValue: unknown;
|
|
113
|
+
readonly lengthValue: unknown;
|
|
114
|
+
readonly quoteValue: unknown;
|
|
115
|
+
}
|
|
116
|
+
export declare function spanShapeFault(request: SpanRequest): SpanRefusal | SpanMembers;
|
|
117
|
+
export declare function resolveSpan(bytes: Buffer, request: SpanRequest): ResolvedSpan | SpanRefusal;
|