@nanobpm/urban 0.68.0 → 0.69.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,98 @@
1
+ # `@nanobpm/urban/context/git` — S3 git substrate + PR-governance
2
+
3
+ The git-as-system-of-record **write + governance** layer. Built on the merged
4
+ S1 (binding), S2 (schema) and S6 (PII guard) slices — it composes them and does
5
+ not re-implement clone/pull, the record schema, or the PII classifier/guard.
6
+
7
+ ## Write paths (`ContextWriter`)
8
+
9
+ Construct a writer from a resolved substrate handle (`{ localPath, ref }` from
10
+ `@nanobpm/urban/context/binding`):
11
+
12
+ ```ts
13
+ import { ContextWriter } from "@nanobpm/urban/context/git";
14
+ const writer = new ContextWriter(resolvedHandle); // no wiring needed
15
+ ```
16
+
17
+ - `appendRecord(record)` — **append-via-commit**: validates the record against
18
+ the S2 schema (incl. the forgery-resistance invariants), runs the mandatory
19
+ PII guard, then commits it on a fresh write branch and merges it onto the base
20
+ branch. For `human` / `measured` / `instance` records. **`agent-retro` is
21
+ rejected here** — a hypothesis must go through governance.
22
+ - `proposePrior(record)` — **PR-governance**: writes the record as a *proposed
23
+ prior* on a `context/proposal/<id>-<rand>` bot branch, left **unmerged**. This
24
+ is the git-only stand-in for a bot PR. Mandatory for `agent-retro`.
25
+ - `ratify(proposal)` — **merges** the bot branch onto the base branch. Merge ==
26
+ ratification. Because the proposal branch may have been created or amended
27
+ outside `proposePrior`, the branch is untrusted, so before merging `ratify`:
28
+ (1) refuses any branch outside the `context/proposal/` namespace; (2) rejects any
29
+ proposal whose `baseBranch` differs from the writer's **own resolved base**, and
30
+ merges onto that resolved base (never the caller-supplied one), so a mutated
31
+ handle can't steer the merge off the line the writer is bound to; (3) bounds the
32
+ branch↔base diff to **exactly** the one proposed record file, so no extra file
33
+ (another record, or content outside the record layout) can ride onto the
34
+ authoritative line **unguarded**; (4) **re-reads, re-validates (S2 schema) and
35
+ re-runs the mandatory PII guard** against that record; and (5) asserts its
36
+ on-disk path is the record's canonical layout path. Only then is it merged — so
37
+ a PII-carrying, invalid, mislocated, smuggled, or misdirected record can never be
38
+ ratified. The ratifying merge's commit message is derived from the **guarded**
39
+ `record.id` (re-read and re-guarded above), never the caller-controlled
40
+ `proposalId`, so a mutated handle can't inject unguarded content (PII, newlines)
41
+ into the commit trail. Every commit/merge subject additionally passes `record.id`
42
+ through `commitLine` (collapses CR/LF and whitespace to a single line), so even a
43
+ guarded id can't split the subject to inject extra message lines or a forged
44
+ trailer — `id` is schema-validated only as a non-empty string.
45
+ It accepts the hypothesis onto the authoritative line; it never upgrades an
46
+ `agent-retro` record to `authoritative` (the S2 schema forbids that), so an
47
+ unratified — or even a ratified — prior can never present as a
48
+ measured/authoritative fact.
49
+ - `isRatified(proposal)` — `true` iff the proposal's commit is an ancestor of the
50
+ writer's **own resolved base**. Like `ratify`, it rejects a proposal whose
51
+ `baseBranch` was mutated off that resolved base rather than trusting the
52
+ plain-object handle, so a retargeted handle can't report an unmerged hypothesis
53
+ as ratified.
54
+
55
+ Concurrency: **within a single `ContextWriter`** operations are serialised on the
56
+ shared working tree (one working tree) and each write still lands on its own
57
+ uniquely-named branch, so interleaved calls never clobber each other and disjoint
58
+ records merge cleanly. This is a **per-instance** guarantee: two `ContextWriter`
59
+ instances (or separate processes) over the **same** working copy are uncoordinated
60
+ and can corrupt each other — use a separate clone (or external locking) per
61
+ concurrent writer. Each mutating op also restores the working tree to the resolved
62
+ base in a `finally` (a force-checkout plus a layout-scoped clean), so a
63
+ mid-operation failure never strands the shared tree on a transient write/proposal
64
+ branch — with a half-written record — for the next serialised call to inherit.
65
+
66
+ ## PII enforcement BY CONSTRUCTION
67
+
68
+ `PreCommitGuardRegistry` is **always** seeded with the mandatory S6
69
+ `preCommitPiiGuard` as its first, non-removable member and has no removal API.
70
+ The writer runs `assertAll` before **every** commit, so a PII-carrying write is
71
+ rejected before it is committed — on the default code path, with no caller
72
+ opt-in. `ratify` (a merge onto the authoritative line) is a write path too, so it
73
+ re-runs `assertAll` against the proposal's content before merging. Additional
74
+ guards can only be added (stricter), never subtracted.
75
+
76
+ ## Path / namespace partitioning (read by S4 & S6-CI)
77
+
78
+ Records are persisted under a stable layout, exported as a small helper so S4
79
+ retrieval and the S6 CI scan read the **same** scheme:
80
+
81
+ ```
82
+ records/<scope>/<scopeRef-bucket>/<id>.json
83
+ ```
84
+
85
+ - `LAYOUT_ROOT` (`"records"`), `RECORD_FILE_EXTENSION` (`".json"`),
86
+ `UNSCOPED_BUCKET` (`"_"`, for records with no `scopeRef`).
87
+ - `scopeDir(scope)`, `recordDir(scope, scopeRef?)`, `recordRelativePath(record)`.
88
+ - `sanitizeSegment(value)` — every path component is reduced to a
89
+ filesystem-safe segment that can never contain a separator or `..`
90
+ (path-traversal safe by construction).
91
+ - `isRecordPath(relPath)` — selects record files while walking the substrate.
92
+
93
+ ## Seam for a future PII/mutable backend
94
+
95
+ The write path is abstracted over the `WriteSubstrate` interface, not hard-wired
96
+ to public git. `GitWriteSubstrate` is the default git-only MVP implementation; a
97
+ future mutable/PII backend (with real erasure) can implement the same interface
98
+ without the writer or its callers changing.
@@ -0,0 +1,59 @@
1
+ // Slice S3 (git/governance) — the mandatory pre-commit guard registry.
2
+ //
3
+ // This is the seam that makes PII enforcement NON-OPTIONAL and default-DENY BY
4
+ // CONSTRUCTION on every write path. A registry is ALWAYS seeded with the S6
5
+ // mandatory guard (`preCommitPiiGuard`) as its first, non-removable member:
6
+ //
7
+ // - there is no method to remove or replace the default guard, and
8
+ // - a freshly-constructed registry (the default the writer uses when a caller
9
+ // supplies no wiring at all) already contains it,
10
+ //
11
+ // so a caller cannot silently bypass PII enforcement. Additional guards can only
12
+ // be ADDED (making the policy stricter), never subtracted. The writer calls
13
+ // {@link PreCommitGuardRegistry.assertAll} immediately before it stages/commits
14
+ // any record, so a write carrying PII is rejected before it is ever committed.
15
+
16
+ import type { PiiCandidate, PiiGuard } from "../pii/index.ts";
17
+ import { preCommitPiiGuard } from "../pii/index.ts";
18
+
19
+ /**
20
+ * An append-only registry of pre-commit guards. The mandatory S6 PII guard is
21
+ * pre-registered as the default and cannot be removed; callers may register
22
+ * ADDITIONAL guards, which run in addition to (never instead of) the default.
23
+ */
24
+ export class PreCommitGuardRegistry {
25
+ // Index 0 is ALWAYS the mandatory S6 PII guard. There is no removal API, so it
26
+ // is impossible to end up with a registry that does not enforce it.
27
+ readonly #guards: PiiGuard[];
28
+
29
+ /**
30
+ * Build a registry. It is seeded with the mandatory {@link preCommitPiiGuard}
31
+ * first; any `additional` guards are appended after it. Even with no arguments
32
+ * the resulting registry enforces PII by construction.
33
+ */
34
+ constructor(additional: readonly PiiGuard[] = []) {
35
+ this.#guards = [preCommitPiiGuard, ...additional];
36
+ }
37
+
38
+ /** Register an ADDITIONAL guard. Cannot displace the mandatory default guard. */
39
+ register(guard: PiiGuard): void {
40
+ this.#guards.push(guard);
41
+ }
42
+
43
+ /** A snapshot of the registered guards, in run order (mandatory guard first). */
44
+ get guards(): readonly PiiGuard[] {
45
+ return [...this.#guards];
46
+ }
47
+
48
+ /**
49
+ * Run EVERY registered guard against `candidate`. The mandatory PII guard runs
50
+ * first. Each guard is default-DENY: the first guard to detect a violation
51
+ * throws (a {@link import("../pii/index.ts").PiiGuardError} for the PII guard),
52
+ * so a candidate carrying PII never reaches the commit that follows this call.
53
+ */
54
+ assertAll(candidate: PiiCandidate): void {
55
+ for (const guard of this.#guards) {
56
+ guard.assert(candidate);
57
+ }
58
+ }
59
+ }
@@ -1,2 +1,51 @@
1
- // Slice S3 (git/governance) — owner replaces this placeholder barrel.
2
- export {};
1
+ // @nanobpm/urban/context/git — slice S3 (git substrate + PR-governance).
2
+ //
3
+ // The git-as-system-of-record WRITE + governance layer, built on top of a
4
+ // resolved substrate handle (S1), the validated record schema (S2), and the
5
+ // mandatory PII pre-commit guard (S6). It publishes three things downstream:
6
+ //
7
+ // 1. APPEND / GOVERNANCE — ContextWriter: append-via-commit for
8
+ // human/measured/instance facts, and PR-governance (propose → ratify) for
9
+ // agent-retro hypotheses, with the mandatory PII guard wired into EVERY write
10
+ // path so PII is rejected before any commit, by construction.
11
+ // 2. PATH / NAMESPACE PARTITIONING — the layout helper (recordRelativePath,
12
+ // recordDir, scopeDir, isRecordPath, LAYOUT_ROOT). S4 retrieval and the S6 CI
13
+ // slice READ this exact layout, so it is exported as a stable seam.
14
+ // 3. THE WRITE SUBSTRATE SEAM — WriteSubstrate / GitWriteSubstrate: the write
15
+ // path is abstracted over the substrate (not hard-wired to public git),
16
+ // leaving room for a future PII/mutable backend, and the
17
+ // PreCommitGuardRegistry that makes the PII guard non-bypassable.
18
+
19
+ export {
20
+ LAYOUT_ROOT,
21
+ RECORD_FILE_EXTENSION,
22
+ UNSCOPED_BUCKET,
23
+ isRecordPath,
24
+ recordDir,
25
+ recordRelativePath,
26
+ sanitizeSegment,
27
+ scopeDir,
28
+ } from "./layout.ts";
29
+
30
+ export { PreCommitGuardRegistry } from "./guard-registry.ts";
31
+
32
+ export {
33
+ type CommitAuthor,
34
+ GitWriteSubstrate,
35
+ SubstrateWriteError,
36
+ substratePath,
37
+ type WriteSubstrate,
38
+ } from "./substrate.ts";
39
+
40
+ export {
41
+ type AppendResult,
42
+ ContextWriter,
43
+ type ContextWriterOptions,
44
+ DEFAULT_BOT_AUTHOR,
45
+ DEFAULT_WRITE_AUTHOR,
46
+ GovernanceError,
47
+ type ProposalResult,
48
+ type RatifyResult,
49
+ type ResolvedSubstrateHandle,
50
+ serialiseRecord,
51
+ } from "./writer.ts";
@@ -0,0 +1,112 @@
1
+ // Slice S3 (git/governance) — unit tests for the path/namespace layout helper.
2
+
3
+ import assert from "node:assert/strict";
4
+ import { test } from "node:test";
5
+ import type { MemoryRecord } from "../schema/index.ts";
6
+ import {
7
+ LAYOUT_ROOT,
8
+ UNSCOPED_BUCKET,
9
+ isRecordPath,
10
+ recordDir,
11
+ recordRelativePath,
12
+ sanitizeSegment,
13
+ scopeDir,
14
+ } from "./layout.ts";
15
+
16
+ function record(overrides: Partial<MemoryRecord>): MemoryRecord {
17
+ const base: MemoryRecord = {
18
+ schemaVersion: 1,
19
+ id: "rec-1",
20
+ scope: "epic",
21
+ mode: "empirical",
22
+ provenance: "measured",
23
+ authority: "authoritative",
24
+ statement: "a measured fact",
25
+ createdAt: "2026-01-01T00:00:00Z",
26
+ };
27
+ return { ...base, ...overrides };
28
+ }
29
+
30
+ test("scopeDir / recordDir partition by scope and scopeRef", () => {
31
+ assert.equal(scopeDir("epic"), `${LAYOUT_ROOT}/epic`);
32
+ assert.equal(recordDir("epic", "issue-303"), `${LAYOUT_ROOT}/epic/issue-303`);
33
+ // No scopeRef → the shared bucket.
34
+ assert.equal(recordDir("corpus"), `${LAYOUT_ROOT}/corpus/${UNSCOPED_BUCKET}`);
35
+ });
36
+
37
+ test("recordRelativePath is deterministic and scoped", () => {
38
+ const r = record({ scope: "epic", scopeRef: "issue-303", id: "rec-42" });
39
+ assert.equal(recordRelativePath(r), `${LAYOUT_ROOT}/epic/issue-303/rec-42.json`);
40
+ // Same record → same path (idempotent write target).
41
+ assert.equal(recordRelativePath(r), recordRelativePath({ ...r }));
42
+ });
43
+
44
+ test("records without scopeRef land in the shared bucket", () => {
45
+ const r = record({ scope: "corpus", id: "global-1" });
46
+ assert.equal(recordRelativePath(r), `${LAYOUT_ROOT}/corpus/${UNSCOPED_BUCKET}/global-1.json`);
47
+ });
48
+
49
+ test("sanitizeSegment neutralises path traversal and separators", () => {
50
+ // A traversal attempt can never produce a `..` or a separator.
51
+ const nasty = sanitizeSegment("../../etc/passwd");
52
+ assert.ok(!nasty.includes("/"));
53
+ assert.ok(!nasty.includes(".."));
54
+ assert.notEqual(nasty, "");
55
+ // A clean value is preserved verbatim.
56
+ assert.equal(sanitizeSegment("rec-1"), "rec-1");
57
+ });
58
+
59
+ test("distinct lossy ids never collide onto one segment", () => {
60
+ const a = sanitizeSegment("a/b");
61
+ const b = sanitizeSegment("a\\b");
62
+ assert.notEqual(a, b);
63
+ });
64
+
65
+ test("a traversal id cannot escape the layout root", () => {
66
+ const r = record({ scope: "epic", scopeRef: "../../escape", id: "../../../root" });
67
+ const path = recordRelativePath(r);
68
+ assert.ok(path.startsWith(`${LAYOUT_ROOT}/epic/`));
69
+ assert.ok(!path.includes(".."));
70
+ });
71
+
72
+ test("isRecordPath selects record files under the layout root", () => {
73
+ assert.equal(isRecordPath("records/epic/issue-303/rec-42.json"), true);
74
+ assert.equal(isRecordPath("./records/corpus/_/global-1.json"), true);
75
+ assert.equal(isRecordPath("records\\epic\\x\\r.json"), true);
76
+ assert.equal(isRecordPath("records/epic/x/notes.md"), false);
77
+ assert.equal(isRecordPath("other/epic/x/r.json"), false);
78
+ assert.equal(isRecordPath("records/../secrets/r.json"), false);
79
+ });
80
+
81
+ test("sanitizeSegment eliminates internal '..' sequences", () => {
82
+ // An internal `..` (e.g. "a..b") must not survive: `isRecordPath` rejects any
83
+ // path containing "..", so a record whose id/scopeRef carried one would be
84
+ // invisible to retrieval/CI. It is disambiguated with a hash, never dropped.
85
+ const seg = sanitizeSegment("a..b");
86
+ assert.ok(!seg.includes(".."));
87
+ assert.notEqual(seg, sanitizeSegment("a.b"));
88
+ });
89
+
90
+ test("a sanitised scopeRef can never collide with UNSCOPED_BUCKET", () => {
91
+ // A raw scopeRef of exactly "_" must NOT map onto the shared unscoped bucket,
92
+ // otherwise a scoped record and an unscoped record share a directory.
93
+ const seg = sanitizeSegment(UNSCOPED_BUCKET);
94
+ assert.notEqual(seg, UNSCOPED_BUCKET);
95
+ assert.notEqual(
96
+ recordDir("epic", UNSCOPED_BUCKET),
97
+ recordDir("epic"),
98
+ );
99
+ });
100
+
101
+ test("every recordRelativePath output is a valid isRecordPath (no writer/scan drift)", () => {
102
+ // The writer's output and the retrieval/CI scanner must agree by construction:
103
+ // any path recordRelativePath emits must be selected by isRecordPath, for even
104
+ // adversarial ids/scopeRefs.
105
+ const adversarial = ["a..b", "..", ".", "_", "../../escape", "", " ", "a/b\\c"];
106
+ for (const scopeRef of adversarial) {
107
+ for (const id of adversarial) {
108
+ const path = recordRelativePath(record({ scope: "epic", scopeRef, id }));
109
+ assert.equal(isRecordPath(path), true, `not a record path: ${path}`);
110
+ }
111
+ }
112
+ });
@@ -0,0 +1,110 @@
1
+ // Slice S3 (git/governance) — the on-disk path / namespace partitioning.
2
+ //
3
+ // Records are persisted into the substrate repo under a stable, deterministic
4
+ // layout keyed by the record's scope (and, where present, its `scopeRef`). This
5
+ // helper is the SINGLE SOURCE OF TRUTH for that layout: the write path (this
6
+ // slice) writes to it, and BOTH S4 (retrieval) and the S6 CI slice READ it, so
7
+ // it is exported as a small, stable surface rather than duplicated per-consumer.
8
+ //
9
+ // The scheme is:
10
+ //
11
+ // records/<scope>/<scopeRef-bucket>/<id>.json
12
+ //
13
+ // where `<scope>` is a controlled-vocabulary ladder level (element … corpus),
14
+ // `<scopeRef-bucket>` is the sanitised `scopeRef` (or the shared `_` bucket when
15
+ // a record carries no `scopeRef`), and `<id>` is the sanitised record id. Every
16
+ // path component is sanitised to a filesystem-safe segment that can never
17
+ // contain a path separator or `..`, so an untrusted `id`/`scopeRef` can never
18
+ // escape the layout root (path-traversal safe by construction).
19
+
20
+ import { createHash } from "node:crypto";
21
+ import type { MemoryRecord, MemoryScope } from "../schema/index.ts";
22
+
23
+ /** The directory (relative to the substrate root) all records live under. */
24
+ export const LAYOUT_ROOT = "records" as const;
25
+
26
+ /** The on-disk extension every serialised record file carries. */
27
+ export const RECORD_FILE_EXTENSION = ".json" as const;
28
+
29
+ /**
30
+ * The bucket directory used for records that carry no `scopeRef` (e.g. a
31
+ * `corpus`-scoped record). `sanitizeSegment` never returns exactly `"_"` — an
32
+ * input that would sanitise to `"_"` is treated as reserved and gets a `_<hash>`
33
+ * form instead — so a sanitised `scopeRef` can never collide with this bucket.
34
+ */
35
+ export const UNSCOPED_BUCKET = "_" as const;
36
+
37
+ /**
38
+ * Reduce an untrusted string to a single filesystem-safe path segment.
39
+ *
40
+ * The result contains only `[A-Za-z0-9._-]`, never a path separator, never a
41
+ * `..` sequence, and is never `""`, `.`, `..`, or exactly `"_"` — so it can
42
+ * never traverse out of its parent directory nor collide with
43
+ * {@link UNSCOPED_BUCKET}. When sanitisation is LOSSY (the cleaned form differs
44
+ * from the input, or collapses to a reserved value) a short hash of the ORIGINAL
45
+ * value is appended, so two distinct raw values can never silently collide onto
46
+ * one segment while a clean value is preserved verbatim.
47
+ */
48
+ export function sanitizeSegment(value: string): string {
49
+ const cleaned = value
50
+ .trim()
51
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
52
+ .replace(/\.{2,}/g, ".")
53
+ .replace(/^[-.]+|[-.]+$/g, "")
54
+ .slice(0, 100);
55
+ const reserved =
56
+ cleaned === "" ||
57
+ cleaned === "." ||
58
+ cleaned === ".." ||
59
+ cleaned === UNSCOPED_BUCKET;
60
+ if (reserved) {
61
+ return `_${shortHash(value)}`;
62
+ }
63
+ // Preserve a clean, reversible value verbatim; disambiguate a lossy one so
64
+ // distinct inputs never map to the same segment.
65
+ return cleaned === value ? cleaned : `${cleaned}-${shortHash(value)}`;
66
+ }
67
+
68
+ function shortHash(value: string): string {
69
+ return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
70
+ }
71
+
72
+ /** The directory (relative to the substrate root) that holds a whole scope. */
73
+ export function scopeDir(scope: MemoryScope): string {
74
+ return `${LAYOUT_ROOT}/${scope}`;
75
+ }
76
+
77
+ /**
78
+ * The directory a record with the given `scope`/`scopeRef` is partitioned into.
79
+ * Records sharing a `(scope, scopeRef)` namespace land in the same directory, so
80
+ * a retrieval slice can list one namespace cheaply.
81
+ */
82
+ export function recordDir(scope: MemoryScope, scopeRef?: string): string {
83
+ const bucket = scopeRef === undefined ? UNSCOPED_BUCKET : sanitizeSegment(scopeRef);
84
+ return `${scopeDir(scope)}/${bucket}`;
85
+ }
86
+
87
+ /**
88
+ * The full path (relative to the substrate root, POSIX separators) a record is
89
+ * persisted at. Deterministic: the same record id/scope always maps to the same
90
+ * path, so re-appending an updated record with the same id overwrites its file
91
+ * in place while git retains the full append-only history.
92
+ */
93
+ export function recordRelativePath(record: MemoryRecord): string {
94
+ return `${recordDir(record.scope, record.scopeRef)}/${sanitizeSegment(record.id)}${RECORD_FILE_EXTENSION}`;
95
+ }
96
+
97
+ /**
98
+ * `true` iff `relPath` looks like a record file under the layout root (used by
99
+ * S4 retrieval and the S6 CI scan to select record files while walking the
100
+ * substrate). Accepts POSIX or native separators and rejects anything that is
101
+ * not a `.json` file beneath `records/`.
102
+ */
103
+ export function isRecordPath(relPath: string): boolean {
104
+ const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
105
+ return (
106
+ normalised.startsWith(`${LAYOUT_ROOT}/`) &&
107
+ normalised.endsWith(RECORD_FILE_EXTENSION) &&
108
+ !normalised.includes("..")
109
+ );
110
+ }