@nanobpm/urban 0.71.0 → 0.72.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/adversarial/harness.d.ts +24 -0
- package/dist/context/adversarial/harness.js +65 -0
- package/dist/runtime/core/context.d.ts +16 -0
- package/dist/runtime/core/modules/scheduler.d.ts +24 -0
- package/dist/runtime/core/modules/scheduler.js +23 -4
- package/dist/runtime/core/modules/workers.d.ts +2 -1
- package/dist/runtime/core/modules/workers.js +11 -2
- package/dist/runtime/core/runtime.d.ts +8 -6
- package/dist/runtime/core/runtime.js +12 -3
- package/package.json +2 -2
- package/src/context/adversarial/conformance-drift.test.ts +89 -0
- package/src/context/adversarial/governance.test.ts +97 -0
- package/src/context/adversarial/harness.ts +84 -0
- package/src/context/adversarial/pii-guard.test.ts +119 -0
- package/src/context/adversarial/provenance-forgery.test.ts +126 -0
- package/src/context/adversarial/seams.test.ts +104 -0
- package/src/context/adversarial/sharing.test.ts +84 -0
- package/src/runtime/core/context.ts +16 -0
- package/src/runtime/core/extensions.test.ts +2 -0
- package/src/runtime/core/modules/actions.test.ts +2 -0
- package/src/runtime/core/modules/api.test.ts +3 -1
- package/src/runtime/core/modules/cancel.test.ts +2 -0
- package/src/runtime/core/modules/connectors.test.ts +2 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
- package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
- package/src/runtime/core/modules/scheduler.test.ts +44 -0
- package/src/runtime/core/modules/scheduler.ts +39 -4
- package/src/runtime/core/modules/surfaces.test.ts +4 -0
- package/src/runtime/core/modules/triggers.test.ts +4 -45
- package/src/runtime/core/modules/workers.test.ts +95 -0
- package/src/runtime/core/modules/workers.ts +16 -2
- package/src/runtime/core/runtime.ts +19 -9
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — the no-PII guard holds BY CONSTRUCTION and under
|
|
2
|
+
// evasion.
|
|
3
|
+
//
|
|
4
|
+
// The DEFAULT S3 write path (no special caller wiring) must reject a
|
|
5
|
+
// PII-carrying record because S3 pre-registers the mandatory S6 guard. These
|
|
6
|
+
// tests prove that, then attempt obfuscated / nested / edge-case PII and confirm
|
|
7
|
+
// the guard still blocks it — and that a caller cannot bypass it.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { after, test } from "node:test";
|
|
11
|
+
import { ContextWriter } from "../git/index.ts";
|
|
12
|
+
import { PiiGuardError, type PiiGuard, classifyPii, preCommitPiiGuard } from "../pii/index.ts";
|
|
13
|
+
import { ContextRetriever } from "../retrieval/index.ts";
|
|
14
|
+
import { cleanup, makeSubstrate, rec } from "./harness.ts";
|
|
15
|
+
|
|
16
|
+
const TEMP_ROOTS: string[] = [];
|
|
17
|
+
after(() => cleanup(TEMP_ROOTS));
|
|
18
|
+
|
|
19
|
+
test("the DEFAULT write path (no wiring) rejects a PII-carrying record", async () => {
|
|
20
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
21
|
+
// Plain default construction — the caller does NOT opt into any guard.
|
|
22
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
23
|
+
|
|
24
|
+
// The mandatory PII guard is present — and FIRST — by construction.
|
|
25
|
+
assert.equal(
|
|
26
|
+
writer.guards[0],
|
|
27
|
+
preCommitPiiGuard,
|
|
28
|
+
"the mandatory PII guard must be registered first",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await assert.rejects(
|
|
32
|
+
() => writer.appendRecord(rec({ id: "pii-plain", statement: "reach me at alice@example.com" })),
|
|
33
|
+
PiiGuardError,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Governance path is guarded too.
|
|
37
|
+
await assert.rejects(
|
|
38
|
+
() =>
|
|
39
|
+
writer.proposePrior(
|
|
40
|
+
rec({
|
|
41
|
+
id: "pii-propose",
|
|
42
|
+
provenance: "agent-retro",
|
|
43
|
+
mode: "normative",
|
|
44
|
+
authority: "hypothesis",
|
|
45
|
+
statement: "ssn 123-45-6789 belongs to the reporter",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
PiiGuardError,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Nothing leaked into the store.
|
|
52
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
53
|
+
assert.equal((await retriever.all()).length, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("PII hidden in a NESTED field (evidence array) is still blocked", async () => {
|
|
57
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
58
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
() =>
|
|
61
|
+
writer.appendRecord(
|
|
62
|
+
rec({
|
|
63
|
+
id: "pii-nested",
|
|
64
|
+
statement: "clean summary",
|
|
65
|
+
evidence: ["run-1", "leaked contact alice@example.com in logs"],
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
PiiGuardError,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("OBFUSCATED email evades a naive scan but not the guard", () => {
|
|
73
|
+
// The classifier's de-obfuscation pass catches spelled-out separators.
|
|
74
|
+
const obfuscated = "contact bob [at] example [dot] com for details";
|
|
75
|
+
const result = classifyPii(obfuscated);
|
|
76
|
+
assert.equal(result.clean, false, "obfuscated email must be detected");
|
|
77
|
+
assert.equal(result.findings.some((f) => f.kind === "email"), true);
|
|
78
|
+
|
|
79
|
+
// The default guard throws on it, mirroring the write path.
|
|
80
|
+
assert.throws(() => preCommitPiiGuard.assert(obfuscated), PiiGuardError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("clean content passes the guard and lands", async () => {
|
|
84
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
85
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
86
|
+
await writer.appendRecord(rec({ id: "clean-1", statement: "throughput improved to 42 rps" }));
|
|
87
|
+
|
|
88
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
89
|
+
assert.equal((await retriever.all()).some((s) => s.record.id === "clean-1"), true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an extra caller guard can only make enforcement STRICTER, never bypass PII", async () => {
|
|
93
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
94
|
+
// A DISTINCT, always-throwing caller guard. If a caller-supplied guard could
|
|
95
|
+
// displace the mandatory PII guard — or run before it — a PII write would
|
|
96
|
+
// surface THIS sentinel error instead of PiiGuardError.
|
|
97
|
+
const alwaysThrows: PiiGuard = {
|
|
98
|
+
name: "always-throws",
|
|
99
|
+
inspect: () => ({ clean: true, findings: [] }),
|
|
100
|
+
assert: () => {
|
|
101
|
+
throw new Error("extra caller guard ran");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const writer = new ContextWriter(
|
|
105
|
+
{ localPath: dir, ref: "main" },
|
|
106
|
+
{ guards: [alwaysThrows] },
|
|
107
|
+
);
|
|
108
|
+
// The mandatory guard is still FIRST; the caller guard is only appended.
|
|
109
|
+
assert.equal(writer.guards[0], preCommitPiiGuard, "mandatory PII guard must run first");
|
|
110
|
+
assert.equal(writer.guards.includes(alwaysThrows), true, "caller guard is added, not dropped");
|
|
111
|
+
|
|
112
|
+
// A PII write is rejected by the mandatory guard (which runs first) — with
|
|
113
|
+
// PiiGuardError, NOT the sentinel — proving the caller guard neither displaced
|
|
114
|
+
// nor pre-empted mandatory enforcement.
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => writer.appendRecord(rec({ id: "pii-extra", statement: "email me: eve@example.org" })),
|
|
117
|
+
PiiGuardError,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — PROVENANCE / AUTHORITY forgery resistance.
|
|
2
|
+
//
|
|
3
|
+
// The headline invariant of the whole layer is "hypothesis-never-fix": an
|
|
4
|
+
// unratified / agent-retrospective prior can never be REPRESENTED as a measured
|
|
5
|
+
// or authoritative fact. These tests attack that invariant directly — at the
|
|
6
|
+
// schema, at the write path, and after ratification — at EVERY scope.
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, test } from "node:test";
|
|
10
|
+
import { ContextWriter } from "../git/index.ts";
|
|
11
|
+
import { ContextRetriever } from "../retrieval/index.ts";
|
|
12
|
+
import {
|
|
13
|
+
MemoryRecordValidationError,
|
|
14
|
+
type MemoryScope,
|
|
15
|
+
SCOPE_LADDER,
|
|
16
|
+
assertMemoryRecord,
|
|
17
|
+
isAuthoritative,
|
|
18
|
+
validateMemoryRecord,
|
|
19
|
+
} from "../schema/index.ts";
|
|
20
|
+
import { cleanup, makeSubstrate, rec } from "./harness.ts";
|
|
21
|
+
|
|
22
|
+
const TEMP_ROOTS: string[] = [];
|
|
23
|
+
after(() => cleanup(TEMP_ROOTS));
|
|
24
|
+
|
|
25
|
+
/** The forgery target: an agent-retro prior masquerading as an authoritative fact. */
|
|
26
|
+
function forgedAtScope(scope: MemoryScope): Record<string, unknown> {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
id: `forged-${scope}`,
|
|
30
|
+
scope,
|
|
31
|
+
mode: "empirical",
|
|
32
|
+
provenance: "agent-retro",
|
|
33
|
+
authority: "authoritative",
|
|
34
|
+
statement: "a hypothesis laundered into a fact",
|
|
35
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("schema: agent-retro can NEVER be authoritative — at every scope", () => {
|
|
40
|
+
for (const scope of SCOPE_LADDER) {
|
|
41
|
+
const result = validateMemoryRecord(forgedAtScope(scope));
|
|
42
|
+
assert.equal(result.ok, false, `scope ${scope}: forged record must be rejected`);
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
assert.equal(
|
|
45
|
+
result.errors.some((e) => e.code === "invariant-violation"),
|
|
46
|
+
true,
|
|
47
|
+
`scope ${scope}: expected an invariant-violation`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
assert.throws(() => assertMemoryRecord(forgedAtScope(scope)), MemoryRecordValidationError);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("schema: no field permutation reaches authoritative via agent-retro", () => {
|
|
55
|
+
// The ONLY provenances that may be authoritative are human/measured/instance.
|
|
56
|
+
for (const mode of ["normative", "empirical"] as const) {
|
|
57
|
+
const result = validateMemoryRecord({
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
id: "x",
|
|
60
|
+
scope: "repo",
|
|
61
|
+
mode,
|
|
62
|
+
provenance: "agent-retro",
|
|
63
|
+
authority: "authoritative",
|
|
64
|
+
statement: "s",
|
|
65
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
66
|
+
});
|
|
67
|
+
assert.equal(result.ok, false, `agent-retro/${mode}/authoritative must be rejected`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("write path: a forged agent-retro-authoritative record cannot be persisted", async () => {
|
|
72
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
73
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
74
|
+
|
|
75
|
+
// Neither write path will commit it: append validates+rejects the invariant
|
|
76
|
+
// (and also refuses agent-retro directly), propose validates+rejects it too.
|
|
77
|
+
await assert.rejects(() => writer.appendRecord(forgedAtScope("epic")));
|
|
78
|
+
await assert.rejects(() => writer.proposePrior(forgedAtScope("epic")));
|
|
79
|
+
|
|
80
|
+
// And nothing landed in the store.
|
|
81
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
82
|
+
const all = await retriever.all();
|
|
83
|
+
assert.equal(all.some((s) => s.record.id === "forged-epic"), false);
|
|
84
|
+
assert.equal(all.length, 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("write path: agent-retro is refused on the DIRECT append path (must go via governance)", async () => {
|
|
88
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
89
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
90
|
+
// A perfectly valid agent-retro hypothesis is still not directly appendable.
|
|
91
|
+
await assert.rejects(() =>
|
|
92
|
+
writer.appendRecord(
|
|
93
|
+
rec({
|
|
94
|
+
id: "retro-direct",
|
|
95
|
+
provenance: "agent-retro",
|
|
96
|
+
mode: "normative",
|
|
97
|
+
authority: "hypothesis",
|
|
98
|
+
statement: "prefer X over Y",
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("ratification does NOT upgrade authority — the merged record stays a hypothesis", async () => {
|
|
105
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
106
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
107
|
+
|
|
108
|
+
const proposal = await writer.proposePrior(
|
|
109
|
+
rec({
|
|
110
|
+
id: "retro-1",
|
|
111
|
+
provenance: "agent-retro",
|
|
112
|
+
mode: "normative",
|
|
113
|
+
authority: "hypothesis",
|
|
114
|
+
statement: "a proposed prior",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
await writer.ratify(proposal);
|
|
118
|
+
|
|
119
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
120
|
+
const found = (await retriever.all()).find((s) => s.record.id === "retro-1");
|
|
121
|
+
assert.ok(found, "the ratified proposal is now on the authoritative line");
|
|
122
|
+
// Ratified ≠ authoritative: the record is an ACCEPTED hypothesis, never a fact.
|
|
123
|
+
assert.equal(found.record.authority, "hypothesis");
|
|
124
|
+
assert.equal(found.record.provenance, "agent-retro");
|
|
125
|
+
assert.equal(isAuthoritative(found.record), false);
|
|
126
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — the pluggable SEAMS don't leak a backend assumption.
|
|
2
|
+
//
|
|
3
|
+
// Retrieval must be correct from a COLD/empty derived cache (git is
|
|
4
|
+
// authoritative, the cache is a disposable projection), and the binding / read
|
|
5
|
+
// seams must NOT hard-wire "exclusively public git" — local paths, file:// URLs
|
|
6
|
+
// and ssh remotes are first-class, and a custom RecordSource can back retrieval
|
|
7
|
+
// without any git at all.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { after, test } from "node:test";
|
|
11
|
+
import { resolveContextIdentity, sameContext } from "../binding/index.ts";
|
|
12
|
+
import { ContextWriter } from "../git/index.ts";
|
|
13
|
+
import {
|
|
14
|
+
ContextRetriever,
|
|
15
|
+
type NamespaceSelector,
|
|
16
|
+
type RecordSource,
|
|
17
|
+
type StoredRecord,
|
|
18
|
+
} from "../retrieval/index.ts";
|
|
19
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
20
|
+
import { cleanup, makeSubstrate, rec } from "./harness.ts";
|
|
21
|
+
|
|
22
|
+
const TEMP_ROOTS: string[] = [];
|
|
23
|
+
after(() => cleanup(TEMP_ROOTS));
|
|
24
|
+
|
|
25
|
+
test("retrieval is correct on a COLD cache — the first query falls back to git", async () => {
|
|
26
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
27
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
28
|
+
await writer.appendRecord(rec({ id: "cold-1", scope: "epic", scopeRef: "issue-303" }));
|
|
29
|
+
await writer.appendRecord(rec({ id: "cold-2", scope: "repo", scopeRef: "nano-ide", statement: "s2" }));
|
|
30
|
+
|
|
31
|
+
// Brand-new retriever: the derived cache is cold. This FIRST query has no
|
|
32
|
+
// warmed snapshot to read, so it falls back to git — and, as a side effect,
|
|
33
|
+
// populates the snapshot. We assert the cold-path result is complete.
|
|
34
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
35
|
+
const cold = await retriever.query({});
|
|
36
|
+
assert.equal(cold.length, 2, "a cold cache must still return every record via git");
|
|
37
|
+
|
|
38
|
+
// A path-scoped structured query on a COLD cache reads just that partition
|
|
39
|
+
// without forcing a full snapshot build. Use a FRESH retriever so the cache is
|
|
40
|
+
// genuinely cold here — the `query({})` above already warmed `retriever`.
|
|
41
|
+
const nsRetriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
42
|
+
assert.equal(nsRetriever.cache.isWarm, false, "a fresh retriever's cache must be cold");
|
|
43
|
+
const scoped = await nsRetriever.byNamespace("repo", "nano-ide");
|
|
44
|
+
assert.equal(scoped.length, 1);
|
|
45
|
+
assert.equal(scoped[0].record.id, "cold-2");
|
|
46
|
+
assert.equal(nsRetriever.cache.isWarm, false, "a single-namespace query must not warm the full cache");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("git is authoritative — the derived cache is disposable and rebuildable", async () => {
|
|
50
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
51
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
52
|
+
await writer.appendRecord(rec({ id: "auth-1" }));
|
|
53
|
+
|
|
54
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
55
|
+
assert.equal((await retriever.all()).length, 1); // warms the cache
|
|
56
|
+
|
|
57
|
+
// A write happens in git AFTER the cache was warmed.
|
|
58
|
+
await writer.appendRecord(rec({ id: "auth-2", scope: "repo", scopeRef: "nano-ide", statement: "later" }));
|
|
59
|
+
|
|
60
|
+
// The stale snapshot doesn't see it; invalidating rebuilds purely from git.
|
|
61
|
+
retriever.invalidate();
|
|
62
|
+
const rebuilt = await retriever.all();
|
|
63
|
+
assert.equal(rebuilt.length, 2, "cache must rebuild the full truth from git");
|
|
64
|
+
assert.equal(rebuilt.some((s) => s.record.id === "auth-2"), true);
|
|
65
|
+
|
|
66
|
+
// The forward-compat embedding seam is opt-in and absent in the MVP.
|
|
67
|
+
assert.equal(retriever.embeddingIndex, undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the READ seam is pluggable — retrieval works over a non-git RecordSource", async () => {
|
|
71
|
+
const record: MemoryRecord = rec({ id: "mem-1", subject: "latency", statement: "backend-agnostic" });
|
|
72
|
+
const stored: StoredRecord = { path: "records/epic/issue-303/mem-1.md", record };
|
|
73
|
+
|
|
74
|
+
// An in-memory source that never touches git — proving the seam isn't
|
|
75
|
+
// hard-wired to "exclusively public git".
|
|
76
|
+
const memorySource: RecordSource = {
|
|
77
|
+
async list(_selector?: NamespaceSelector) {
|
|
78
|
+
return [stored];
|
|
79
|
+
},
|
|
80
|
+
async read(relPath: string) {
|
|
81
|
+
return relPath === stored.path ? stored : undefined;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const retriever = new ContextRetriever("/unused/path", { source: memorySource });
|
|
86
|
+
const all = await retriever.all();
|
|
87
|
+
assert.equal(all.length, 1);
|
|
88
|
+
assert.equal(all[0].record.id, "mem-1");
|
|
89
|
+
const bySubject = await retriever.query({ subject: "latency" });
|
|
90
|
+
assert.equal(bySubject.length, 1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("binding identity is NOT hard-wired to public GitHub — local/file/ssh are first-class", () => {
|
|
94
|
+
// A local path and its file:// spelling name the SAME (self-hosted) context.
|
|
95
|
+
const localPath = "/srv/substrates/ctx";
|
|
96
|
+
const local = resolveContextIdentity({ repo: localPath, ref: "main" });
|
|
97
|
+
assert.ok(local.key.length > 0, "a local-path substrate must resolve to an identity");
|
|
98
|
+
assert.equal(sameContext({ repo: localPath, ref: "main" }, { repo: `file://${localPath}`, ref: "main" }), true);
|
|
99
|
+
|
|
100
|
+
// An ssh remote resolves too, and differs from an unrelated host.
|
|
101
|
+
const ssh = resolveContextIdentity({ repo: "git@example.com:team/ctx.git", ref: "main" });
|
|
102
|
+
assert.ok(ssh.key.length > 0);
|
|
103
|
+
assert.notEqual(ssh.key, local.key);
|
|
104
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — cross-instance SHARING semantics.
|
|
2
|
+
//
|
|
3
|
+
// Proves the "private-per-app / shared-on-same-name" contract can't be broken:
|
|
4
|
+
// two instances naming the SAME context (repo+ref) see each other's ratified
|
|
5
|
+
// writes, and two instances naming DISTINCT contexts stay isolated. Exercised at
|
|
6
|
+
// both the S1 identity layer and end-to-end through S3's write path + S4 reads.
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, test } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
type ContextBinding,
|
|
12
|
+
contextIdentityKey,
|
|
13
|
+
resolveContextIdentity,
|
|
14
|
+
sameContext,
|
|
15
|
+
} from "../binding/index.ts";
|
|
16
|
+
import { ContextWriter } from "../git/index.ts";
|
|
17
|
+
import { ContextRetriever } from "../retrieval/index.ts";
|
|
18
|
+
import { cleanup, makeSubstrate, rec } from "./harness.ts";
|
|
19
|
+
|
|
20
|
+
const TEMP_ROOTS: string[] = [];
|
|
21
|
+
after(() => cleanup(TEMP_ROOTS));
|
|
22
|
+
|
|
23
|
+
const binding = (repo: string, ref: string): ContextBinding => ({ repo, ref });
|
|
24
|
+
|
|
25
|
+
test("identity: same (repo, ref) name shares; a distinct name stays private", () => {
|
|
26
|
+
const a = binding("nanobpm/nano-ide", "main");
|
|
27
|
+
const same = binding("nanobpm/nano-ide", "main");
|
|
28
|
+
const otherRef = binding("nanobpm/nano-ide", "release");
|
|
29
|
+
const otherRepo = binding("nanobpm/nano-workforce", "main");
|
|
30
|
+
|
|
31
|
+
assert.equal(sameContext(a, same), true);
|
|
32
|
+
assert.equal(contextIdentityKey(a), contextIdentityKey(same));
|
|
33
|
+
|
|
34
|
+
// A different ref OR a different repo is a genuinely different context.
|
|
35
|
+
assert.equal(sameContext(a, otherRef), false);
|
|
36
|
+
assert.equal(sameContext(a, otherRepo), false);
|
|
37
|
+
assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRef));
|
|
38
|
+
assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRepo));
|
|
39
|
+
|
|
40
|
+
// On-disk slug tracks the key: shared names → shared location; distinct → not.
|
|
41
|
+
assert.equal(resolveContextIdentity(a).slug, resolveContextIdentity(same).slug);
|
|
42
|
+
assert.notEqual(resolveContextIdentity(a).slug, resolveContextIdentity(otherRef).slug);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("identity: equivalent repo spellings collapse to one shared substrate", () => {
|
|
46
|
+
// Shorthand, HTTPS clone URL, and a credentialed URL all name one context.
|
|
47
|
+
// The credentialed form uses the repo's redacted userinfo placeholder (`***@`)
|
|
48
|
+
// — it still exercises the "userinfo must not affect identity" invariant
|
|
49
|
+
// without embedding a credential-in-URL literal that trips secret scanning.
|
|
50
|
+
const shorthand = binding("nanobpm/nano-ide", "main");
|
|
51
|
+
const https = binding("https://github.com/nanobpm/nano-ide.git", "main");
|
|
52
|
+
const credential = binding("https://***@github.com/nanobpm/nano-ide.git", "main");
|
|
53
|
+
|
|
54
|
+
assert.equal(sameContext(shorthand, https), true);
|
|
55
|
+
assert.equal(sameContext(shorthand, credential), true, "credentials must not split identity");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("end-to-end: a second instance on the same context reads the first's write", async () => {
|
|
59
|
+
const shared = await makeSubstrate(TEMP_ROOTS);
|
|
60
|
+
|
|
61
|
+
// Instance A writes a ratified fact into the shared substrate.
|
|
62
|
+
const writerA = new ContextWriter({ localPath: shared, ref: "main" });
|
|
63
|
+
await writerA.appendRecord(
|
|
64
|
+
rec({ id: "shared-fact", scope: "epic", scopeRef: "issue-303", statement: "A observed 42 rps" }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Instance B — a distinct object naming the SAME context — sees it.
|
|
68
|
+
const retrieverB = new ContextRetriever({ localPath: shared, ref: "main" });
|
|
69
|
+
const seenByB = await retrieverB.all();
|
|
70
|
+
assert.equal(seenByB.some((s) => s.record.id === "shared-fact"), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("end-to-end: a distinct-name instance cannot see another context's writes", async () => {
|
|
74
|
+
const shared = await makeSubstrate(TEMP_ROOTS);
|
|
75
|
+
const other = await makeSubstrate(TEMP_ROOTS);
|
|
76
|
+
|
|
77
|
+
const writerA = new ContextWriter({ localPath: shared, ref: "main" });
|
|
78
|
+
await writerA.appendRecord(rec({ id: "private-fact", statement: "only visible in shared" }));
|
|
79
|
+
|
|
80
|
+
const retrieverOther = new ContextRetriever({ localPath: other, ref: "main" });
|
|
81
|
+
const seen = await retrieverOther.all();
|
|
82
|
+
assert.equal(seen.some((s) => s.record.id === "private-fact"), false);
|
|
83
|
+
assert.equal(seen.length, 0, "an unrelated context must stay empty/private");
|
|
84
|
+
});
|
|
@@ -42,6 +42,22 @@ export interface AppApi {
|
|
|
42
42
|
*/
|
|
43
43
|
sdk?: EngineSdkClient;
|
|
44
44
|
env(name: string): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* App clock seam — current time in ms since epoch, sourced from the runtime's injectable
|
|
47
|
+
* scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
|
|
48
|
+
* clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
|
|
49
|
+
* budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
|
|
50
|
+
* bounds it deterministically rather than the loop burning real wall-time. See
|
|
51
|
+
* {@link AppApi.wait}.
|
|
52
|
+
*/
|
|
53
|
+
now(): number;
|
|
54
|
+
/**
|
|
55
|
+
* Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
|
|
56
|
+
* scheduler. Real timers in production (no behavior change on the live transport); under
|
|
57
|
+
* the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
|
|
58
|
+
* sleep in time-bounded work so the delay shares the engine's (virtual) clock.
|
|
59
|
+
*/
|
|
60
|
+
wait(ms: number): Promise<void>;
|
|
45
61
|
/**
|
|
46
62
|
* Structured logger (see {@link Logger}). Callable for back-compat
|
|
47
63
|
* (`log("info", msg, fields)`), but prefer the level methods and bound context:
|
|
@@ -39,7 +39,7 @@ const engine: EngineClient = {
|
|
|
39
39
|
const data = new DataLayer(new Map(), undefined, {});
|
|
40
40
|
|
|
41
41
|
function appFixture(): AppApi {
|
|
42
|
-
return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: createLogger(() => {}) };
|
|
42
|
+
return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, now: () => 0, wait: () => Promise.resolve(), log: createLogger(() => {}) };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const spec = JSON.stringify({
|
|
@@ -148,6 +148,8 @@ test("a delegate's app.log is bound to the request's correlation context", async
|
|
|
148
148
|
data,
|
|
149
149
|
engine,
|
|
150
150
|
env: () => undefined,
|
|
151
|
+
now: () => 0,
|
|
152
|
+
wait: () => Promise.resolve(),
|
|
151
153
|
log: createLogger((level, msg, fields) => {
|
|
152
154
|
captured.push({ level, msg, fields });
|
|
153
155
|
}),
|
|
@@ -111,6 +111,8 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
|
|
|
111
111
|
data,
|
|
112
112
|
engine,
|
|
113
113
|
env: () => undefined,
|
|
114
|
+
now: () => 0,
|
|
115
|
+
wait: () => Promise.resolve(),
|
|
114
116
|
log: createLogger((level, msg) => {
|
|
115
117
|
logs.push({ level, msg });
|
|
116
118
|
}),
|
|
@@ -86,6 +86,8 @@ function makeApp(env: Record<string, string> = {}): AppApi {
|
|
|
86
86
|
data: new DataLayer(new Map(), undefined, {}),
|
|
87
87
|
engine: new MiniEngine(),
|
|
88
88
|
env: (n: string) => env[n],
|
|
89
|
+
now: () => 0,
|
|
90
|
+
wait: () => Promise.resolve(),
|
|
89
91
|
log: createLogger(() => {}),
|
|
90
92
|
};
|
|
91
93
|
}
|
|
@@ -17,53 +17,13 @@ import {
|
|
|
17
17
|
WAITING_HUMAN_PROBE_CONCURRENCY,
|
|
18
18
|
} from "./instance-tracking.ts";
|
|
19
19
|
import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
20
|
+
import { fakeScheduler } from "./scheduler.test-utils.ts";
|
|
20
21
|
|
|
21
22
|
// A real-timer flush drains the entire pending microtask chain (find → engine → update)
|
|
22
23
|
// each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
|
|
23
24
|
const realSetTimeout = globalThis.setTimeout;
|
|
24
25
|
const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
|
|
25
26
|
|
|
26
|
-
// A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
|
|
27
|
-
// timer in order, draining microtasks between fires so the async reconcile completes.
|
|
28
|
-
function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
|
|
29
|
-
let clock = 0;
|
|
30
|
-
let seq = 0;
|
|
31
|
-
const timers = new Map<number, { at: number; fn: () => void }>();
|
|
32
|
-
return {
|
|
33
|
-
now: () => clock,
|
|
34
|
-
setTimer: (fn, delayMs) => {
|
|
35
|
-
const id = ++seq;
|
|
36
|
-
timers.set(id, { at: clock + delayMs, fn });
|
|
37
|
-
return id;
|
|
38
|
-
},
|
|
39
|
-
clearTimer: (h) => {
|
|
40
|
-
if (typeof h === "number") timers.delete(h);
|
|
41
|
-
},
|
|
42
|
-
pending: () => timers.size,
|
|
43
|
-
advance: async (ms) => {
|
|
44
|
-
const target = clock + ms;
|
|
45
|
-
// deno-lint-ignore no-constant-condition
|
|
46
|
-
while (true) {
|
|
47
|
-
let nextId = -1;
|
|
48
|
-
let nextAt = Infinity;
|
|
49
|
-
for (const [id, t] of timers) {
|
|
50
|
-
if (t.at <= target && t.at < nextAt) {
|
|
51
|
-
nextAt = t.at;
|
|
52
|
-
nextId = id;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (nextId < 0) break;
|
|
56
|
-
const t = timers.get(nextId)!;
|
|
57
|
-
timers.delete(nextId);
|
|
58
|
-
clock = t.at;
|
|
59
|
-
t.fn();
|
|
60
|
-
await flush();
|
|
61
|
-
}
|
|
62
|
-
clock = target;
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
27
|
/** An EngineClient driven by a fixed map of processInstanceKey → state (for searchProcessInstances)
|
|
68
28
|
* and an optional map of processInstanceKey → number-of-open-user-tasks (for openUserTasks, the
|
|
69
29
|
* wait-on-human edge). Records the keys asked for on each call. `openUserTasks` models the engine
|
|
@@ -167,6 +127,8 @@ async function withHarness(
|
|
|
167
127
|
data,
|
|
168
128
|
engine,
|
|
169
129
|
env: () => undefined,
|
|
130
|
+
now: () => 0,
|
|
131
|
+
wait: () => Promise.resolve(),
|
|
170
132
|
log: createLogger((level, msg) => {
|
|
171
133
|
logs.push({ level, msg });
|
|
172
134
|
}),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared test-only fake for the injectable {@link SchedulerDeps} timer/clock seam. A single
|
|
2
|
+
// canonical virtual-clock scheduler for every time-based test (workers, triggers, the
|
|
3
|
+
// instance-tracking reconciler) so their virtual-time behavior can't drift apart — the drift
|
|
4
|
+
// surface three per-file copies used to create. Excluded from the published build via the
|
|
5
|
+
// `**/*.test-utils.ts` rule in tsconfig.build.json.
|
|
6
|
+
|
|
7
|
+
import type { SchedulerDeps } from "./scheduler.ts";
|
|
8
|
+
|
|
9
|
+
// A real-timer flush drains the entire pending microtask chain each time a fake timer fires —
|
|
10
|
+
// deeper than a fixed number of `await Promise.resolve()`s, so an async follow-on chain (find →
|
|
11
|
+
// engine → update, or a worker loop's next iteration) fully settles before the next timer fires.
|
|
12
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
13
|
+
const flush = (): Promise<void> => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
|
|
14
|
+
|
|
15
|
+
/** A deterministic virtual-clock {@link SchedulerDeps}: timers fire only when the clock is advanced
|
|
16
|
+
* past their deadline. `advance(ms)` fires every timer due within the interval in order, draining
|
|
17
|
+
* microtasks between fires so an async loop's follow-on chain settles before the next fire, then
|
|
18
|
+
* parks the clock at the target. `pending()` reports the number of armed timers. A non-positive /
|
|
19
|
+
* non-finite delay clamps to fire at the current instant, mirroring the production
|
|
20
|
+
* `schedulerClock` / `setTimer` clamp. Lets a test bound time-based work over virtual time without
|
|
21
|
+
* touching the real wall clock. */
|
|
22
|
+
export function fakeScheduler(
|
|
23
|
+
startMs = 0,
|
|
24
|
+
): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
|
|
25
|
+
let clock = startMs;
|
|
26
|
+
let seq = 0;
|
|
27
|
+
const timers = new Map<number, { at: number; fn: () => void }>();
|
|
28
|
+
return {
|
|
29
|
+
now: () => clock,
|
|
30
|
+
setTimer: (fn, delayMs) => {
|
|
31
|
+
const id = ++seq;
|
|
32
|
+
timers.set(id, { at: clock + (Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0), fn });
|
|
33
|
+
return id;
|
|
34
|
+
},
|
|
35
|
+
clearTimer: (h) => {
|
|
36
|
+
if (typeof h === "number") timers.delete(h);
|
|
37
|
+
},
|
|
38
|
+
pending: () => timers.size,
|
|
39
|
+
advance: async (ms) => {
|
|
40
|
+
const target = clock + Math.max(0, ms);
|
|
41
|
+
// Fire due timers one at a time; a timer's callback may schedule a new one.
|
|
42
|
+
for (;;) {
|
|
43
|
+
let nextId = -1;
|
|
44
|
+
let nextAt = Number.POSITIVE_INFINITY;
|
|
45
|
+
for (const [id, t] of timers) {
|
|
46
|
+
if (t.at <= target && t.at < nextAt) {
|
|
47
|
+
nextAt = t.at;
|
|
48
|
+
nextId = id;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (nextId < 0) break;
|
|
52
|
+
const t = timers.get(nextId);
|
|
53
|
+
if (!t) break;
|
|
54
|
+
timers.delete(nextId);
|
|
55
|
+
clock = t.at;
|
|
56
|
+
t.fn();
|
|
57
|
+
await flush();
|
|
58
|
+
}
|
|
59
|
+
clock = target;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|