@nanobpm/urban 0.69.1 → 0.71.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/context/retrieval/cache.d.ts +35 -0
- package/dist/context/retrieval/cache.js +75 -0
- package/dist/context/retrieval/embedding.d.ts +27 -0
- package/dist/context/retrieval/embedding.js +13 -0
- package/dist/context/retrieval/index.d.ts +5 -1
- package/dist/context/retrieval/index.js +21 -1
- package/dist/context/retrieval/query.d.ts +50 -0
- package/dist/context/retrieval/query.js +62 -0
- package/dist/context/retrieval/retriever.d.ts +63 -0
- package/dist/context/retrieval/retriever.js +136 -0
- package/dist/context/retrieval/source.d.ts +71 -0
- package/dist/context/retrieval/source.js +141 -0
- package/package.json +1 -1
- package/src/context/README.md +44 -0
- package/src/context/pii/check-pii.test.ts +196 -0
- package/src/context/retrieval/NOTES.md +68 -0
- package/src/context/retrieval/cache.ts +95 -0
- package/src/context/retrieval/embedding.ts +45 -0
- package/src/context/retrieval/index.ts +47 -2
- package/src/context/retrieval/query.test.ts +72 -0
- package/src/context/retrieval/query.ts +118 -0
- package/src/context/retrieval/retriever.test.ts +187 -0
- package/src/context/retrieval/retriever.ts +193 -0
- package/src/context/retrieval/source.test.ts +118 -0
- package/src/context/retrieval/source.ts +218 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — the READ seam over the git system-of-record.
|
|
2
|
+
//
|
|
3
|
+
// Retrieval treats git as the authoritative store: records live on disk under
|
|
4
|
+
// the exact layout S3 defines (`records/<scope>/<scopeRef-bucket>/<id>.json`),
|
|
5
|
+
// and this module reads them back. It is deliberately abstracted behind a
|
|
6
|
+
// {@link RecordSource} interface so the derived hot cache, the query layer, and
|
|
7
|
+
// a future PII/mutable backend all read through one seam rather than assuming a
|
|
8
|
+
// public-git working copy. {@link GitRecordSource} is the default MVP: it walks
|
|
9
|
+
// a resolved substrate working copy (the local checkout S1 produces, pinned to
|
|
10
|
+
// its ref) using the S3 layout helpers to select record files.
|
|
11
|
+
//
|
|
12
|
+
// The source NEVER caches — it always reflects what is on disk (== the git ref
|
|
13
|
+
// that is checked out). Caching is the derived index's job (see cache.ts), and
|
|
14
|
+
// the index is disposable: a cold/empty index falls back to reading here, so
|
|
15
|
+
// retrieval is correct even before any index is built.
|
|
16
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
17
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
18
|
+
import { isRecordPath, LAYOUT_ROOT, recordDir, scopeDir, } from "../git/index.js";
|
|
19
|
+
import { validateMemoryRecord, } from "../schema/index.js";
|
|
20
|
+
/** Thrown when the underlying substrate cannot be read at all. */
|
|
21
|
+
export class RecordSourceError extends Error {
|
|
22
|
+
constructor(message, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = "RecordSourceError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isEnoent(error) {
|
|
28
|
+
return (typeof error === "object" &&
|
|
29
|
+
error !== null &&
|
|
30
|
+
"code" in error &&
|
|
31
|
+
error.code === "ENOENT");
|
|
32
|
+
}
|
|
33
|
+
/** Convert a native absolute path to a substrate-root-relative POSIX path. */
|
|
34
|
+
function toPosixRelative(root, absPath) {
|
|
35
|
+
return relative(root, absPath).split(sep).join("/");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The default git-backed read source: reads records straight off a local
|
|
39
|
+
* substrate working copy. Git is the system of record; this holds no state of
|
|
40
|
+
* its own, so every call reflects the current on-disk (== checked-out ref)
|
|
41
|
+
* content. That is what makes the derived index safe to throw away and rebuild.
|
|
42
|
+
*/
|
|
43
|
+
export class GitRecordSource {
|
|
44
|
+
#root;
|
|
45
|
+
constructor(handle) {
|
|
46
|
+
const root = typeof handle === "string" ? handle : handle.localPath;
|
|
47
|
+
if (!isAbsolute(root)) {
|
|
48
|
+
throw new RecordSourceError(`substrate root must be an absolute path, got: ${root}`);
|
|
49
|
+
}
|
|
50
|
+
this.#root = root;
|
|
51
|
+
}
|
|
52
|
+
/** Absolute path of the substrate working copy this source reads from. */
|
|
53
|
+
get root() {
|
|
54
|
+
return this.#root;
|
|
55
|
+
}
|
|
56
|
+
async list(selector) {
|
|
57
|
+
const subtree = selectorSubtree(selector);
|
|
58
|
+
const walkRoot = join(this.#root, subtree);
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// A missing subtree (e.g. an empty store, or a namespace with no records)
|
|
65
|
+
// is an empty result, not an error — a cold read must degrade gracefully.
|
|
66
|
+
if (isEnoent(error)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
throw new RecordSourceError(`failed to list substrate at ${walkRoot}`, {
|
|
70
|
+
cause: error,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const results = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isFile()) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const absPath = join(entry.parentPath, entry.name);
|
|
79
|
+
const relPath = toPosixRelative(this.#root, absPath);
|
|
80
|
+
if (!isRecordPath(relPath)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const stored = await this.#readAbsolute(absPath, relPath);
|
|
84
|
+
if (stored !== undefined) {
|
|
85
|
+
results.push(stored);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Deterministic order regardless of filesystem enumeration order.
|
|
89
|
+
results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
async read(relPath) {
|
|
93
|
+
const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
94
|
+
if (!isRecordPath(normalised)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return this.#readAbsolute(join(this.#root, normalised), normalised);
|
|
98
|
+
}
|
|
99
|
+
async #readAbsolute(absPath, relPath) {
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = await readFile(absPath, "utf8");
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (isEnoent(error)) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
throw new RecordSourceError(`failed to read record at ${relPath}`, {
|
|
109
|
+
cause: error,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// A corrupt / non-JSON file under the layout is skipped, not fatal.
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const result = validateMemoryRecord(parsed);
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
// A file that does not validate against the S2 schema is not a record we
|
|
123
|
+
// can safely surface — skip it rather than return an unchecked shape.
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return { path: relPath, record: result.record };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The layout subtree to walk for a selector: one namespace bucket, one whole
|
|
131
|
+
* scope, or the entire layout root. Keeps path-scoping in one place so the
|
|
132
|
+
* cache and query layers never re-derive layout paths themselves.
|
|
133
|
+
*/
|
|
134
|
+
export function selectorSubtree(selector) {
|
|
135
|
+
if (selector === undefined) {
|
|
136
|
+
return LAYOUT_ROOT;
|
|
137
|
+
}
|
|
138
|
+
return selector.scopeRef === undefined
|
|
139
|
+
? scopeDir(selector.scope)
|
|
140
|
+
: recordDir(selector.scope, selector.scopeRef);
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/context/README.md
CHANGED
|
@@ -58,3 +58,47 @@ barrel.
|
|
|
58
58
|
- `npm run test:conformance --workspace @nanobpm/urban` runs the conformance
|
|
59
59
|
corpus.
|
|
60
60
|
- `npm run check:pii --workspace @nanobpm/urban` runs the PII gate.
|
|
61
|
+
|
|
62
|
+
## PII: no-PII by construction, and the immutability-vs-erasure boundary
|
|
63
|
+
|
|
64
|
+
The MVP substrate is **no-PII by construction**. Nothing here stores personal
|
|
65
|
+
data, and two independent nets keep it that way:
|
|
66
|
+
|
|
67
|
+
1. **In-process, at write time (S6-core + S3).** `@nanobpm/urban/context/pii`
|
|
68
|
+
ships a pure classifier (`classifyPii`) and a mandatory, default-DENY
|
|
69
|
+
pre-commit guard (`preCommitPiiGuard`). S3's `ContextWriter` registers that
|
|
70
|
+
guard as a **non-optional** pre-commit step on **every** write path
|
|
71
|
+
(`PreCommitGuardRegistry`, seeded non-removably), so a record carrying PII is
|
|
72
|
+
rejected **before any commit** — a caller cannot opt out.
|
|
73
|
+
|
|
74
|
+
2. **Build-time, in CI (this slice, s6-pii-ci).** `npm run check:pii` walks the
|
|
75
|
+
S3 record layout (`records/<scope>/<scopeRef-bucket>/<id>.json`, where
|
|
76
|
+
`<scopeRef-bucket>` is the sanitised `scopeRef` or the shared `_` bucket when a
|
|
77
|
+
record carries no `scopeRef` — see `recordRelativePath`) and re-classifies
|
|
78
|
+
every record with the **same** `classifyPii`, failing the build on any violation.
|
|
79
|
+
The `.github/workflows/urban-context-pii.yml` workflow runs it (plus
|
|
80
|
+
build/typecheck/test) as a second line of defence in case content reaches a
|
|
81
|
+
git-backed context by some path other than the writer.
|
|
82
|
+
|
|
83
|
+
### Why the guard, not erasure
|
|
84
|
+
|
|
85
|
+
The substrate is an **append-only git history**. "Deleting" a record is just
|
|
86
|
+
another commit that removes the file from the *tip*; every prior value remains
|
|
87
|
+
in the reachable history (and in every clone, fork, and reflog). On this
|
|
88
|
+
substrate there is **no true erasure** — you cannot unpublish a fact that was
|
|
89
|
+
ever committed short of a history rewrite that every consumer must also adopt.
|
|
90
|
+
Because real erasure is not achievable append-only, the only sound MVP policy is
|
|
91
|
+
to **never admit PII in the first place** — hence the by-construction guard + CI
|
|
92
|
+
net above rather than an after-the-fact delete.
|
|
93
|
+
|
|
94
|
+
### The seam left for a future PII/mutable backend
|
|
95
|
+
|
|
96
|
+
Real erasure (GDPR-style "right to be forgotten", tombstoning, crypto-shredding)
|
|
97
|
+
requires a **mutable** substrate that can genuinely destroy a value, not merely
|
|
98
|
+
append over it. The write path is already abstracted over the substrate
|
|
99
|
+
(`WriteSubstrate` / `GitWriteSubstrate` in `@nanobpm/urban/context/git`) and the
|
|
100
|
+
binding layer does not assume "public git only", so a future opt-in
|
|
101
|
+
PII/mutable backend can slot in behind that seam and offer erasure semantics
|
|
102
|
+
**without** touching the record schema, the layout, or this guard. That backend
|
|
103
|
+
is intentionally **not built** in the MVP — only its seam is documented here, and
|
|
104
|
+
the deferred implementation is tracked in nano-ide#393.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Slice s6-pii-ci — tests for the build-time PII gate (`scripts/check-pii.ts`).
|
|
2
|
+
//
|
|
3
|
+
// These seed records into the SAME S3 path/namespace layout the writer uses
|
|
4
|
+
// (`recordRelativePath`), then run the real check-pii.ts script against a local
|
|
5
|
+
// temp substrate and assert its exit code: non-zero on a seeded PII violation,
|
|
6
|
+
// zero on clean content. Nothing touches the network.
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { after, test } from "node:test";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { recordRelativePath } from "../git/index.ts";
|
|
17
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
// packages/urban/src/context/pii → packages/urban/scripts/check-pii.ts
|
|
22
|
+
const CHECK_PII = join(HERE, "..", "..", "..", "scripts", "check-pii.ts");
|
|
23
|
+
const TEMP_ROOTS: string[] = [];
|
|
24
|
+
|
|
25
|
+
after(async () => {
|
|
26
|
+
await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function record(overrides: Partial<MemoryRecord>): MemoryRecord {
|
|
30
|
+
const base: MemoryRecord = {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
id: "rec-1",
|
|
33
|
+
scope: "epic",
|
|
34
|
+
scopeRef: "issue-303",
|
|
35
|
+
mode: "empirical",
|
|
36
|
+
provenance: "measured",
|
|
37
|
+
authority: "authoritative",
|
|
38
|
+
statement: "the measured throughput was 42 rps",
|
|
39
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
40
|
+
};
|
|
41
|
+
return { ...base, ...overrides };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a record into a substrate root at its canonical S3 layout path. */
|
|
45
|
+
async function seed(root: string, rec: MemoryRecord): Promise<void> {
|
|
46
|
+
const rel = recordRelativePath(rec);
|
|
47
|
+
const abs = join(root, rel);
|
|
48
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
49
|
+
await writeFile(abs, `${JSON.stringify(rec, null, 2)}\n`, "utf8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write raw file content into a substrate root at a record's canonical path. */
|
|
53
|
+
async function seedRaw(root: string, rec: MemoryRecord, content: string): Promise<void> {
|
|
54
|
+
const rel = recordRelativePath(rec);
|
|
55
|
+
const abs = join(root, rel);
|
|
56
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
57
|
+
await writeFile(abs, content, "utf8");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function makeRoot(): Promise<string> {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), "s6ci-substrate-"));
|
|
62
|
+
TEMP_ROOTS.push(dir);
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run check-pii.ts against a root; resolve with its exit code + stderr. */
|
|
67
|
+
async function runCheck(root: string): Promise<{ code: number; stderr: string; stdout: string }> {
|
|
68
|
+
try {
|
|
69
|
+
const { stdout, stderr } = await execFileAsync(
|
|
70
|
+
process.execPath,
|
|
71
|
+
["--experimental-strip-types", CHECK_PII, root],
|
|
72
|
+
{ cwd: root },
|
|
73
|
+
);
|
|
74
|
+
return { code: 0, stdout, stderr };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
code: numberProp(error, "code") ?? 1,
|
|
78
|
+
stdout: stringProp(error, "stdout"),
|
|
79
|
+
stderr: stringProp(error, "stderr"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function numberProp(error: unknown, key: string): number | undefined {
|
|
85
|
+
const value =
|
|
86
|
+
typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
|
|
87
|
+
return typeof value === "number" ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stringProp(error: unknown, key: string): string {
|
|
91
|
+
const value =
|
|
92
|
+
typeof error === "object" && error !== null ? Reflect.get(error, key) : undefined;
|
|
93
|
+
return typeof value === "string" ? value : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("check:pii passes (exit 0) on clean records in the S3 layout", async () => {
|
|
97
|
+
const root = await makeRoot();
|
|
98
|
+
await seed(root, record({ id: "clean-a" }));
|
|
99
|
+
await seed(
|
|
100
|
+
root,
|
|
101
|
+
record({ id: "clean-b", scope: "repo", scopeRef: undefined, statement: "latency held at 42ms" }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const { code, stdout } = await runCheck(root);
|
|
105
|
+
assert.equal(code, 0, `expected clean content to pass; stdout=${stdout}`);
|
|
106
|
+
assert.match(stdout, /no PII detected/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("check:pii fails (non-zero) on a seeded PII violation in the S3 layout", async () => {
|
|
110
|
+
const root = await makeRoot();
|
|
111
|
+
await seed(root, record({ id: "clean-a" }));
|
|
112
|
+
// A record whose statement carries an email address — a PII violation.
|
|
113
|
+
await seed(
|
|
114
|
+
root,
|
|
115
|
+
record({ id: "leaky", statement: "reported by alice.example@contoso.com during the run" }),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const { code, stderr } = await runCheck(root);
|
|
119
|
+
assert.equal(code, 1, "expected a seeded PII violation to fail the build");
|
|
120
|
+
assert.match(stderr, /PII detected/);
|
|
121
|
+
assert.match(stderr, /email/);
|
|
122
|
+
// The gate must NOT re-leak the raw PII it caught (length-only redaction).
|
|
123
|
+
assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("check:pii catches PII in a JSON array record (arrays are not plain objects)", async () => {
|
|
127
|
+
const root = await makeRoot();
|
|
128
|
+
// A record file whose top-level JSON value is an array carrying an email
|
|
129
|
+
// address. Arrays must NOT be narrowed as plain objects — they fall through
|
|
130
|
+
// to the raw-text scan, so the leak is still caught.
|
|
131
|
+
await seedRaw(
|
|
132
|
+
root,
|
|
133
|
+
record({ id: "arr-leak" }),
|
|
134
|
+
`${JSON.stringify(["contact alice.example@contoso.com for details"], null, 2)}\n`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const { code, stderr } = await runCheck(root);
|
|
138
|
+
assert.equal(code, 1, "expected a PII violation inside a JSON array record to fail the build");
|
|
139
|
+
assert.match(stderr, /PII detected/);
|
|
140
|
+
assert.match(stderr, /email/);
|
|
141
|
+
assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("check:pii is a no-op (exit 0) on a root with no records/ layout", async () => {
|
|
145
|
+
const root = await makeRoot();
|
|
146
|
+
const { code, stdout } = await runCheck(root);
|
|
147
|
+
assert.equal(code, 0);
|
|
148
|
+
assert.match(stdout, /0 record\(s\) scanned/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("check:pii does not follow a symlink record into a target outside the root", async () => {
|
|
152
|
+
const root = await makeRoot();
|
|
153
|
+
// A secret file OUTSIDE the substrate root (e.g. a CI secret the scanner
|
|
154
|
+
// should never touch), carrying an email address.
|
|
155
|
+
const secret = join(await makeRoot(), "ci-secret.txt");
|
|
156
|
+
await writeFile(secret, "token owner: alice.example@contoso.com\n", "utf8");
|
|
157
|
+
// A symlink placed at a canonical record path that points at that secret. The
|
|
158
|
+
// scanner must reject non-regular files (symlinks) up front — otherwise a
|
|
159
|
+
// crafted `records/**.json` symlink would make it read and classify content
|
|
160
|
+
// outside the intended substrate root.
|
|
161
|
+
const rel = recordRelativePath(record({ id: "evil-link" }));
|
|
162
|
+
const linkPath = join(root, rel);
|
|
163
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
164
|
+
await symlink(secret, linkPath);
|
|
165
|
+
|
|
166
|
+
const { code, stdout } = await runCheck(root);
|
|
167
|
+
assert.equal(code, 0, `symlinked record must be skipped, not scanned; stdout=${stdout}`);
|
|
168
|
+
assert.match(stdout, /0 record\(s\) scanned/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("check:pii does not follow a symlinked DIRECTORY into a target outside the root", async () => {
|
|
172
|
+
const root = await makeRoot();
|
|
173
|
+
// A directory OUTSIDE the substrate root holding a record-shaped file that
|
|
174
|
+
// carries an email address — content the scanner must never reach.
|
|
175
|
+
const outsideDir = join(await makeRoot(), "outside-bucket");
|
|
176
|
+
await mkdir(outsideDir, { recursive: true });
|
|
177
|
+
await writeFile(
|
|
178
|
+
join(outsideDir, "rec-leak.json"),
|
|
179
|
+
`${JSON.stringify(record({ id: "rec-leak", statement: "owned by alice.example@contoso.com" }), null, 2)}\n`,
|
|
180
|
+
"utf8",
|
|
181
|
+
);
|
|
182
|
+
// Symlink an INTERMEDIATE bucket directory in the layout to that outside dir.
|
|
183
|
+
// `records/epic/issue-303` → outsideDir makes `records/epic/issue-303/rec-leak.json`
|
|
184
|
+
// look like a canonical record path. `readdir(recursive:true)` would follow
|
|
185
|
+
// the symlinked directory and scan (and re-leak) the outside file; the walk
|
|
186
|
+
// must refuse to descend into any symlinked directory.
|
|
187
|
+
const bucketLink = join(root, "records", "epic", "issue-303");
|
|
188
|
+
await mkdir(dirname(bucketLink), { recursive: true });
|
|
189
|
+
await symlink(outsideDir, bucketLink);
|
|
190
|
+
|
|
191
|
+
const { code, stdout, stderr } = await runCheck(root);
|
|
192
|
+
assert.equal(code, 0, `symlinked bucket dir must not be traversed; stderr=${stderr}`);
|
|
193
|
+
assert.match(stdout, /0 record\(s\) scanned/);
|
|
194
|
+
// And it must never re-leak the outside PII it refused to scan.
|
|
195
|
+
assert.doesNotMatch(stderr, /alice\.example@contoso\.com/);
|
|
196
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# `@nanobpm/urban/context/retrieval` — S4 retrieval over git
|
|
2
|
+
|
|
3
|
+
The **read** side of the Urban context layer. It queries the git
|
|
4
|
+
system-of-record that S3 (`@nanobpm/urban/context/git`) writes, using the exact
|
|
5
|
+
path/namespace layout S3 publishes — it does **not** invent its own layout.
|
|
6
|
+
MVP is **git-only**: no external service.
|
|
7
|
+
|
|
8
|
+
## Query surface (`ContextRetriever`)
|
|
9
|
+
|
|
10
|
+
Bind a retriever to a resolved substrate handle (the local checkout S1 produces,
|
|
11
|
+
`{ localPath, ref }`):
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { ContextRetriever } from "@nanobpm/urban/context/retrieval";
|
|
15
|
+
const retriever = new ContextRetriever({ localPath, ref: "main" });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `query(q)` — the general query. Clauses are **ANDed**; a clause given as an
|
|
19
|
+
array is an **OR-set**. Supports:
|
|
20
|
+
- **structured / path-scoped**: `scope`, `scopeRef` (uses S3's partitioning);
|
|
21
|
+
- **frontmatter filtering**: `mode`, `provenance`, `authority`, `id`,
|
|
22
|
+
`subject` (typed record fields);
|
|
23
|
+
- **text search**: `text` — case-insensitive substring across the record body
|
|
24
|
+
(`statement`, `subject`, `id`);
|
|
25
|
+
- `limit`.
|
|
26
|
+
- `byNamespace(scope, scopeRef?)` — path-scoped structured query for one layout
|
|
27
|
+
partition; reads just that subtree when the cache is cold.
|
|
28
|
+
- `search(text, filters?)` — text search combined with structured filters.
|
|
29
|
+
- `all()` — every record (warms the cache).
|
|
30
|
+
- `get(relPath)` — one record by its S3 layout path (bypasses the cache).
|
|
31
|
+
|
|
32
|
+
## Git is the system of record; the cache is DERIVED and disposable
|
|
33
|
+
|
|
34
|
+
Retrieval is correct **with a cold/empty cache** — every query falls back to
|
|
35
|
+
reading git through the `RecordSource` seam. A query pinned to exactly one
|
|
36
|
+
namespace (`scope` + a single `scopeRef`) reads **only that layout partition**,
|
|
37
|
+
so a targeted query never pays to build the whole index.
|
|
38
|
+
|
|
39
|
+
The hot cache (`DerivedRecordIndex`) is a **snapshot projection**, rebuildable
|
|
40
|
+
purely from git and never authoritative:
|
|
41
|
+
|
|
42
|
+
- `retriever.invalidate()` — drop the projection; the next read re-reads git.
|
|
43
|
+
- `retriever.rebuild()` — eagerly re-project the whole store from git.
|
|
44
|
+
- `retriever.cache.isWarm` — whether a snapshot is currently held.
|
|
45
|
+
|
|
46
|
+
Callers keep the cache honest by calling `invalidate()` after a write (git
|
|
47
|
+
remains the source of truth). A warm cache serves its snapshot until
|
|
48
|
+
invalidated/rebuilt — it does not observe writes made after it was warmed.
|
|
49
|
+
|
|
50
|
+
## Read seam (`RecordSource` / `GitRecordSource`)
|
|
51
|
+
|
|
52
|
+
Reads go through a `RecordSource` interface, not a hard-wired public-git
|
|
53
|
+
assumption. `GitRecordSource` is the default git-only MVP: it walks the resolved
|
|
54
|
+
working copy, selects record files with S3's `isRecordPath`, and validates each
|
|
55
|
+
against the S2 schema (`validateMemoryRecord`). Corrupt / non-record / invalid
|
|
56
|
+
files under the layout are **skipped, not fatal**. A future PII/mutable backend
|
|
57
|
+
can implement `RecordSource` without the cache, query layer, or callers changing.
|
|
58
|
+
|
|
59
|
+
## Seam for a future opt-in embedding index (NOT built)
|
|
60
|
+
|
|
61
|
+
Semantic/vector retrieval (an OpenMemory / mem0-style **derived** embedding
|
|
62
|
+
index) is deliberately **not built** here — MVP retrieval is git-only lexical.
|
|
63
|
+
`embedding.ts` declares the `EmbeddingIndex` seam it would plug into
|
|
64
|
+
(`reindex(records)` + `semanticSearch(query, k)`), and `ContextRetriever`
|
|
65
|
+
accepts an optional `embeddingIndex` for forward-compat. Any such index MUST be
|
|
66
|
+
derived and disposable (rebuildable from git, never authoritative), exactly like
|
|
67
|
+
the hot cache — a cold embedding index must degrade to the git-backed lexical
|
|
68
|
+
path, never to stale/wrong results.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — the DERIVED hot read cache/index.
|
|
2
|
+
//
|
|
3
|
+
// Git is the system of record; this index is a disposable, rebuildable-from-git
|
|
4
|
+
// projection kept hot for fast repeated reads. It is NEVER authoritative:
|
|
5
|
+
// - a cold (never-built) index transparently reads through to the source on
|
|
6
|
+
// first use, so retrieval is correct even before any rebuild;
|
|
7
|
+
// - `invalidate()` drops the projection so the next read re-reads from git;
|
|
8
|
+
// - `rebuild()` re-reads the whole store eagerly.
|
|
9
|
+
// Because it holds nothing git does not, throwing it away can never lose data.
|
|
10
|
+
|
|
11
|
+
import type { NamespaceSelector, RecordSource, StoredRecord } from "./source.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A derived, in-memory index over a {@link RecordSource}. Holds a full snapshot
|
|
15
|
+
* of the store once warmed; cold reads and namespace-scoped reads fall back to
|
|
16
|
+
* the source directly so a cold/empty index is always correct.
|
|
17
|
+
*/
|
|
18
|
+
export class DerivedRecordIndex {
|
|
19
|
+
readonly #source: RecordSource;
|
|
20
|
+
// `undefined` == cold (never loaded / invalidated). An empty array is a valid
|
|
21
|
+
// WARM state (a store that genuinely has no records), distinct from cold.
|
|
22
|
+
#snapshot: readonly StoredRecord[] | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(source: RecordSource) {
|
|
25
|
+
this.#source = source;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `true` once a full snapshot is loaded; `false` when cold/invalidated. */
|
|
29
|
+
get isWarm(): boolean {
|
|
30
|
+
return this.#snapshot !== undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The read source this index is derived from (git, by default). */
|
|
34
|
+
get source(): RecordSource {
|
|
35
|
+
return this.#source;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Eagerly (re)load the full store snapshot from the source, replacing any
|
|
40
|
+
* existing projection. Cheap to call and idempotent — the whole point is that
|
|
41
|
+
* rebuilding is always safe because git remains the source of truth.
|
|
42
|
+
*/
|
|
43
|
+
async rebuild(): Promise<readonly StoredRecord[]> {
|
|
44
|
+
const snapshot = await this.#source.list();
|
|
45
|
+
this.#snapshot = snapshot;
|
|
46
|
+
return snapshot;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Drop the projection so the next read re-reads from git. */
|
|
50
|
+
invalidate(): void {
|
|
51
|
+
this.#snapshot = undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return the full store, warming the index on a cold read. After this the
|
|
56
|
+
* index is warm; subsequent calls serve the cached snapshot without touching
|
|
57
|
+
* the filesystem until {@link invalidate} or {@link rebuild} is called.
|
|
58
|
+
*/
|
|
59
|
+
async all(): Promise<readonly StoredRecord[]> {
|
|
60
|
+
if (this.#snapshot !== undefined) {
|
|
61
|
+
return this.#snapshot;
|
|
62
|
+
}
|
|
63
|
+
return this.rebuild();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return the records in one namespace partition. When the index is already
|
|
68
|
+
* warm this filters the in-memory snapshot (no I/O); when cold it does a
|
|
69
|
+
* cheap, path-scoped read of JUST that partition from the source WITHOUT
|
|
70
|
+
* warming the whole index — so a targeted query never pays to load the store.
|
|
71
|
+
*/
|
|
72
|
+
async namespace(
|
|
73
|
+
selector: NamespaceSelector,
|
|
74
|
+
): Promise<readonly StoredRecord[]> {
|
|
75
|
+
if (this.#snapshot !== undefined) {
|
|
76
|
+
return this.#snapshot.filter((stored) =>
|
|
77
|
+
inNamespace(stored, selector),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return this.#source.list(selector);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `true` iff a stored record belongs to the given namespace partition. */
|
|
85
|
+
function inNamespace(
|
|
86
|
+
stored: StoredRecord,
|
|
87
|
+
selector: NamespaceSelector,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (stored.record.scope !== selector.scope) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
selector.scopeRef === undefined || stored.record.scopeRef === selector.scopeRef
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Slice S4 (retrieval) — SEAM ONLY for a future opt-in derived embedding index.
|
|
2
|
+
//
|
|
3
|
+
// The MVP retrieval is git-only: structured/path-scoped queries, frontmatter
|
|
4
|
+
// filtering, and substring text search over the git system-of-record, with a
|
|
5
|
+
// disposable hot cache. Semantic (vector) retrieval — an OpenMemory / mem0-style
|
|
6
|
+
// derived embedding index — is deliberately NOT built here. This file only
|
|
7
|
+
// declares the seam it would plug into, so a later slice can add it without
|
|
8
|
+
// reshaping the retriever or its public API.
|
|
9
|
+
//
|
|
10
|
+
// Like the hot cache, any such embedding index MUST be DERIVED and disposable:
|
|
11
|
+
// rebuildable purely from git, never authoritative. Nothing in this file
|
|
12
|
+
// performs embedding, network I/O, or persistence — it is a type contract.
|
|
13
|
+
|
|
14
|
+
import type { RetrievalQuery } from "./query.ts";
|
|
15
|
+
import type { StoredRecord } from "./source.ts";
|
|
16
|
+
|
|
17
|
+
/** A semantic match: a stored record plus the backend's similarity score. */
|
|
18
|
+
export interface SemanticMatch {
|
|
19
|
+
readonly stored: StoredRecord;
|
|
20
|
+
/** Backend-defined similarity score; higher is more similar. */
|
|
21
|
+
readonly score: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The seam a future opt-in embedding index would implement. A retriever could
|
|
26
|
+
* accept one and offer `semanticQuery`, but the MVP neither ships nor requires
|
|
27
|
+
* an implementation — retrieval is fully correct git-only without it.
|
|
28
|
+
*
|
|
29
|
+
* Implementations must treat the embedding store as a derived projection of git:
|
|
30
|
+
* {@link reindex} rebuilds it from the authoritative records, and a cold/missing
|
|
31
|
+
* embedding index must degrade to the git-backed lexical path, never to wrong
|
|
32
|
+
* or stale results.
|
|
33
|
+
*/
|
|
34
|
+
export interface EmbeddingIndex {
|
|
35
|
+
/** (Re)build the derived vector index from the authoritative git records. */
|
|
36
|
+
reindex(records: readonly StoredRecord[]): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Return the semantically nearest records to `query.text`, honouring the
|
|
39
|
+
* structured/frontmatter clauses of `query` as pre/post filters.
|
|
40
|
+
*/
|
|
41
|
+
semanticSearch(
|
|
42
|
+
query: RetrievalQuery,
|
|
43
|
+
k: number,
|
|
44
|
+
): Promise<readonly SemanticMatch[]>;
|
|
45
|
+
}
|
|
@@ -1,2 +1,47 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// @nanobpm/urban/context/retrieval — slice S4 (retrieval over git).
|
|
2
|
+
//
|
|
3
|
+
// Read-side of the Urban context layer: query the git system-of-record that S3
|
|
4
|
+
// writes, using the exact path/namespace layout S3 publishes. MVP is git-only —
|
|
5
|
+
// NO external service:
|
|
6
|
+
//
|
|
7
|
+
// 1. STRUCTURED / PATH-SCOPED queries by scope/namespace, reading just one
|
|
8
|
+
// layout partition when a query pins it (cheap, cold-cache friendly).
|
|
9
|
+
// 2. FRONTMATTER filtering on typed record fields (scope/mode/provenance/
|
|
10
|
+
// authority/id/subject), ANDed clauses with OR-sets per field.
|
|
11
|
+
// 3. TEXT SEARCH across record bodies (case-insensitive substring).
|
|
12
|
+
//
|
|
13
|
+
// Git is the system of record; the hot read cache (DerivedRecordIndex) is a
|
|
14
|
+
// DISPOSABLE projection, rebuildable purely from git and never authoritative —
|
|
15
|
+
// retrieval is correct even with a cold/empty cache by falling back to the
|
|
16
|
+
// source. A future opt-in derived embedding index is left as a documented seam
|
|
17
|
+
// (see embedding.ts / EmbeddingIndex) and is NOT built here.
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
ContextRetriever,
|
|
21
|
+
type ContextRetrieverOptions,
|
|
22
|
+
} from "./retriever.ts";
|
|
23
|
+
|
|
24
|
+
export { DerivedRecordIndex } from "./cache.ts";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
GitRecordSource,
|
|
28
|
+
type NamespaceSelector,
|
|
29
|
+
type RecordSource,
|
|
30
|
+
RecordSourceError,
|
|
31
|
+
type ResolvedReadHandle,
|
|
32
|
+
selectorSubtree,
|
|
33
|
+
type StoredRecord,
|
|
34
|
+
} from "./source.ts";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
compileQuery,
|
|
38
|
+
type CompiledQuery,
|
|
39
|
+
matchesQuery,
|
|
40
|
+
type OneOrMany,
|
|
41
|
+
type RetrievalQuery,
|
|
42
|
+
} from "./query.ts";
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
type EmbeddingIndex,
|
|
46
|
+
type SemanticMatch,
|
|
47
|
+
} from "./embedding.ts";
|