@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.
@@ -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.69.1",
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",
@@ -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
- // Slice S4 (retrieval) — owner replaces this placeholder barrel.
2
- export {};
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";