@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.
Files changed (32) hide show
  1. package/dist/context/adversarial/harness.d.ts +24 -0
  2. package/dist/context/adversarial/harness.js +65 -0
  3. package/dist/runtime/core/context.d.ts +16 -0
  4. package/dist/runtime/core/modules/scheduler.d.ts +24 -0
  5. package/dist/runtime/core/modules/scheduler.js +23 -4
  6. package/dist/runtime/core/modules/workers.d.ts +2 -1
  7. package/dist/runtime/core/modules/workers.js +11 -2
  8. package/dist/runtime/core/runtime.d.ts +8 -6
  9. package/dist/runtime/core/runtime.js +12 -3
  10. package/package.json +2 -2
  11. package/src/context/adversarial/conformance-drift.test.ts +89 -0
  12. package/src/context/adversarial/governance.test.ts +97 -0
  13. package/src/context/adversarial/harness.ts +84 -0
  14. package/src/context/adversarial/pii-guard.test.ts +119 -0
  15. package/src/context/adversarial/provenance-forgery.test.ts +126 -0
  16. package/src/context/adversarial/seams.test.ts +104 -0
  17. package/src/context/adversarial/sharing.test.ts +84 -0
  18. package/src/runtime/core/context.ts +16 -0
  19. package/src/runtime/core/extensions.test.ts +2 -0
  20. package/src/runtime/core/modules/actions.test.ts +2 -0
  21. package/src/runtime/core/modules/api.test.ts +3 -1
  22. package/src/runtime/core/modules/cancel.test.ts +2 -0
  23. package/src/runtime/core/modules/connectors.test.ts +2 -0
  24. package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
  25. package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
  26. package/src/runtime/core/modules/scheduler.test.ts +44 -0
  27. package/src/runtime/core/modules/scheduler.ts +39 -4
  28. package/src/runtime/core/modules/surfaces.test.ts +4 -0
  29. package/src/runtime/core/modules/triggers.test.ts +4 -45
  30. package/src/runtime/core/modules/workers.test.ts +95 -0
  31. package/src/runtime/core/modules/workers.ts +16 -2
  32. package/src/runtime/core/runtime.ts +19 -9
@@ -0,0 +1,24 @@
1
+ import type { MemoryRecord } from "../schema/index.ts";
2
+ /**
3
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
4
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
5
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
6
+ * hook from global templates or local config — keeping the harness deterministic
7
+ * and closing the CI code-execution vector.
8
+ */
9
+ export declare function git(cwd: string, ...args: string[]): Promise<string>;
10
+ /**
11
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
12
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
13
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
14
+ * reads.
15
+ */
16
+ export declare function makeSubstrate(roots: string[]): Promise<string>;
17
+ /**
18
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
19
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
20
+ * otherwise-correct suite.
21
+ */
22
+ export declare function cleanup(roots: readonly string[]): Promise<void>;
23
+ /** A well-formed measured/authoritative record, with per-test overrides. */
24
+ export declare function rec(overrides?: Partial<MemoryRecord>): MemoryRecord;
@@ -0,0 +1,65 @@
1
+ // Slice S7 (adversarial) — shared test harness.
2
+ //
3
+ // This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
4
+ // and NOT part of the published surface (the top barrel never re-exports the
5
+ // adversarial slice). It only provides the local temp-git-substrate helpers the
6
+ // adversarial suites drive S3's REAL write path through. Nothing here touches the
7
+ // network or any external service.
8
+ import { execFile } from "node:child_process";
9
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { hardenedGitArgs } from "../binding/git-backend.js";
14
+ const execFileAsync = promisify(execFile);
15
+ /**
16
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
17
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
18
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
19
+ * hook from global templates or local config — keeping the harness deterministic
20
+ * and closing the CI code-execution vector.
21
+ */
22
+ export async function git(cwd, ...args) {
23
+ const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
24
+ return stdout.trim();
25
+ }
26
+ /**
27
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
28
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
29
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
30
+ * reads.
31
+ */
32
+ export async function makeSubstrate(roots) {
33
+ const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
34
+ roots.push(dir);
35
+ await git(dir, "init");
36
+ await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
37
+ await git(dir, "add", "-A");
38
+ await git(dir, "-c", "user.name=seed", "-c", "user.email=seed@nanobpm.local", "commit", "--no-gpg-sign", "-m", "seed");
39
+ // Normalise the default branch name regardless of the host git's default.
40
+ await git(dir, "branch", "-M", "main");
41
+ return dir;
42
+ }
43
+ /**
44
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
45
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
46
+ * otherwise-correct suite.
47
+ */
48
+ export async function cleanup(roots) {
49
+ await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)));
50
+ }
51
+ /** A well-formed measured/authoritative record, with per-test overrides. */
52
+ export function rec(overrides = {}) {
53
+ const base = {
54
+ schemaVersion: 1,
55
+ id: "rec-1",
56
+ scope: "epic",
57
+ scopeRef: "issue-303",
58
+ mode: "empirical",
59
+ provenance: "measured",
60
+ authority: "authoritative",
61
+ statement: "the measured throughput was 42 rps",
62
+ createdAt: "2026-01-01T00:00:00Z",
63
+ };
64
+ return { ...base, ...overrides };
65
+ }
@@ -37,6 +37,22 @@ export interface AppApi {
37
37
  */
38
38
  sdk?: EngineSdkClient;
39
39
  env(name: string): string | undefined;
40
+ /**
41
+ * App clock seam — current time in ms since epoch, sourced from the runtime's injectable
42
+ * scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
43
+ * clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
44
+ * budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
45
+ * bounds it deterministically rather than the loop burning real wall-time. See
46
+ * {@link AppApi.wait}.
47
+ */
48
+ now(): number;
49
+ /**
50
+ * Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
51
+ * scheduler. Real timers in production (no behavior change on the live transport); under
52
+ * the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
53
+ * sleep in time-bounded work so the delay shares the engine's (virtual) clock.
54
+ */
55
+ wait(ms: number): Promise<void>;
40
56
  /**
41
57
  * Structured logger (see {@link Logger}). Callable for back-compat
42
58
  * (`log("info", msg, fields)`), but prefer the level methods and bound context:
@@ -9,3 +9,27 @@ export interface SchedulerDeps {
9
9
  export declare const MAX_TIMER_DELAY_MS = 2147483647;
10
10
  /** Default (live) scheduler seam, backed by the global timer functions. */
11
11
  export declare function defaultScheduler(): SchedulerDeps;
12
+ /**
13
+ * The app-facing clock/`wait` seam handed to handlers (see `AppApi.now`/`AppApi.wait`).
14
+ * A narrow read-only projection of a {@link SchedulerDeps}: `now()` reads its clock and
15
+ * `wait(ms)` sleeps on its timer. Real wall clock + real timers in production; the
16
+ * virtual clock under the test kit — so time-bounded worker work advances with
17
+ * `advanceTime()` instead of burning real wall-time.
18
+ */
19
+ export interface AppClock {
20
+ /** Current time in ms since epoch on the scheduler's clock. */
21
+ now(): number;
22
+ /** Resolve after `ms` have elapsed on the scheduler's clock (armed via its timer seam). */
23
+ wait(ms: number): Promise<void>;
24
+ }
25
+ /**
26
+ * Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
27
+ * runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
28
+ * handlers doing time-bounded work share the same clock as the background loops (no drift
29
+ * between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
30
+ * delay clamps to fire at the current instant, and a far-future delay clamps to
31
+ * {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
32
+ * `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
33
+ * never reintroduce a Node `TimeoutOverflowWarning`.
34
+ */
35
+ export declare function schedulerClock(sched: SchedulerDeps): AppClock;
@@ -1,8 +1,8 @@
1
1
  // The injectable timer + clock seam shared by every background loop in urban (cron
2
- // triggers, the instance-tracking reconciler). Keeping one definition here — rather
3
- // than a copy per module — means a test's fake scheduler drives them all the same
4
- // way, and the "clamp a far-future delay to setTimeout's 32-bit range" rule has a
5
- // single home.
2
+ // triggers, the instance-tracking reconciler) and the app clock/`wait` seam handed to
3
+ // worker handlers. Keeping one definition here — rather than a copy per module — means a
4
+ // test's fake scheduler drives them all the same way, and the "clamp a far-future delay to
5
+ // setTimeout's 32-bit range" rule has a single home.
6
6
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
7
7
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
8
  function isTimerHandle(handle) {
@@ -19,3 +19,22 @@ export function defaultScheduler() {
19
19
  now: () => Date.now(),
20
20
  };
21
21
  }
22
+ /**
23
+ * Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
24
+ * runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
25
+ * handlers doing time-bounded work share the same clock as the background loops (no drift
26
+ * between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
27
+ * delay clamps to fire at the current instant, and a far-future delay clamps to
28
+ * {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
29
+ * `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
30
+ * never reintroduce a Node `TimeoutOverflowWarning`.
31
+ */
32
+ export function schedulerClock(sched) {
33
+ return {
34
+ now: () => sched.now(),
35
+ wait: (ms) => new Promise((resolve) => {
36
+ const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
37
+ sched.setTimer(() => resolve(), delay);
38
+ }),
39
+ };
40
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import type { EngineJob } from "../host.ts";
3
3
  import { type DecisionEvaluator } from "./llm.ts";
4
+ import { type SchedulerDeps } from "./scheduler.ts";
4
5
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
5
6
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
6
7
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -34,4 +35,4 @@ export interface WorkersHandle extends Mounted {
34
35
  readonly jobTypes: string[];
35
36
  }
36
37
  /** Load handler modules and register a push worker per declared worker. */
37
- export declare function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle>;
38
+ export declare function mountWorkers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): Promise<WorkersHandle>;
@@ -7,6 +7,7 @@ import { runLlmJob } from "./llm.js";
7
7
  import { runInJobContext } from "../execContext.js";
8
8
  import { readLineage } from "../lineage.js";
9
9
  import { LineageStore } from "./lineage-store.js";
10
+ import { defaultScheduler, schedulerClock } from "./scheduler.js";
10
11
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
11
12
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
12
13
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -125,10 +126,18 @@ export function resolveHandler(mod, jobType) {
125
126
  return undefined;
126
127
  }
127
128
  /** Load handler modules and register a push worker per declared worker. */
128
- export async function mountWorkers(ctx, app) {
129
+ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
129
130
  const decls = ctx.manifest.workers ?? [];
130
131
  const subs = [];
131
132
  const jobTypes = [];
133
+ // Bind the app clock/`wait` seam to the injected scheduler, so a handler's time-bounded
134
+ // work (poll loops, backoff, budgets) sources `now`/`wait` from the same clock that drives
135
+ // the engine's timers and the other background loops. Under the test kit that is the
136
+ // virtual clock, so `advanceTime()` bounds the loop instead of it burning real wall-time;
137
+ // in production it is the live scheduler (real timers) — no behavior change. Mirrors how
138
+ // `mountTriggers`/`mountInstanceTracking` receive the same seam.
139
+ const clock = schedulerClock(sched);
140
+ const clockedApp = { ...app, now: clock.now, wait: clock.wait };
132
141
  // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
133
142
  // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
134
143
  // app configures no default data source.
@@ -195,7 +204,7 @@ export async function mountWorkers(ctx, app) {
195
204
  const wrapped = (job) => {
196
205
  if (lineageStore)
197
206
  recordJobLineage(lineageStore, job, ctx.host.log);
198
- return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(app, job)));
207
+ return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(clockedApp, job)));
199
208
  };
200
209
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
201
210
  workerName: `${ctx.manifest.id}:${jobType}`,
@@ -2,7 +2,7 @@ import type { EngineClient, HostContext } from "./host.ts";
2
2
  import { type AppManifest } from "./manifest.ts";
3
3
  import { DataLayer } from "./modules/datasource.ts";
4
4
  import { type Logger } from "./logger.ts";
5
- import type { SchedulerDeps } from "./modules/scheduler.ts";
5
+ import { type SchedulerDeps } from "./modules/scheduler.ts";
6
6
  import { type SecurityPolicy } from "./modules/security.ts";
7
7
  import { type UrbanEvents, type UrbanExtension } from "./extensions.ts";
8
8
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
@@ -34,11 +34,13 @@ export interface CreateUrbanAppOptions {
34
34
  mount?: MountFlags;
35
35
  /**
36
36
  * Injectable timer + clock seam driving every background loop (the cron trigger loops
37
- * and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
38
- * wall clock). The e2e test kit injects a manual scheduler so those loops advance
39
- * deterministically over a virtual clock — the single seam that makes a whole-app
40
- * `settle()` possible. Threaded verbatim into `mountTriggers` and `mountInstanceTracking`
41
- * so one injected scheduler drives them both (no per-module wiring, no drift).
37
+ * and the instance-tracking reconciler) and the app clock/`wait` seam handed to worker
38
+ * handlers. Defaults to the live scheduler (real timers, wall clock). The e2e test kit
39
+ * injects a manual scheduler so those loops — and any time-bounded worker sourcing
40
+ * `app.now()`/`app.wait()` — advance deterministically over a virtual clock: the single
41
+ * seam that makes a whole-app `settle()` possible. Threaded verbatim into `mountWorkers`,
42
+ * `mountTriggers` and `mountInstanceTracking` so one injected scheduler drives them all
43
+ * (no per-module wiring, no drift).
42
44
  */
43
45
  scheduler?: SchedulerDeps;
44
46
  /**
@@ -14,6 +14,7 @@ import { mountConnectors } from "./modules/connectors.js";
14
14
  import { mountSurfaces } from "./modules/surfaces.js";
15
15
  import { mountTriggers } from "./modules/triggers.js";
16
16
  import { mountInstanceTracking } from "./modules/instance-tracking.js";
17
+ import { defaultScheduler, schedulerClock } from "./modules/scheduler.js";
17
18
  import { mountSecurity } from "./modules/security.js";
18
19
  import { EventBus } from "./events.js";
19
20
  import { createUrbanEvents, mountExtensions } from "./extensions.js";
@@ -157,6 +158,12 @@ export async function createUrbanApp(opts) {
157
158
  if (flags.deploy)
158
159
  describe.deploy = await deployModels(ctx);
159
160
  data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
161
+ // Resolve the injectable scheduler once (default: live real-timer seam) so the app
162
+ // clock/`wait` seam handed to handlers and every background loop share one clock —
163
+ // the single source the test kit swaps for a virtual clock to make `settle()` /
164
+ // `advanceTime()` bound the whole app (workers included).
165
+ const scheduler = opts.scheduler ?? defaultScheduler();
166
+ const clock = schedulerClock(scheduler);
160
167
  const api = {
161
168
  manifest,
162
169
  data,
@@ -164,9 +171,11 @@ export async function createUrbanApp(opts) {
164
171
  sdk: engineSdk(engine),
165
172
  env: (n) => host.env(n),
166
173
  log: appLog,
174
+ now: clock.now,
175
+ wait: clock.wait,
167
176
  };
168
177
  if (flags.workers) {
169
- const w = await mountWorkers(ctx, api);
178
+ const w = await mountWorkers(ctx, api, scheduler);
170
179
  mounted.push(w);
171
180
  describe.workers = w.describe?.();
172
181
  // Connector-pack workers share the `workers` flag: same lifecycle, same
@@ -191,13 +200,13 @@ export async function createUrbanApp(opts) {
191
200
  describe.surfaces = s.describe();
192
201
  }
193
202
  if (flags.triggers) {
194
- const t = mountTriggers(ctx, api, opts.scheduler);
203
+ const t = mountTriggers(ctx, api, scheduler);
195
204
  routes.push(...t.routes);
196
205
  mounted.push(t);
197
206
  describe.triggers = t.describe?.();
198
207
  }
199
208
  if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
200
- const it = mountInstanceTracking(ctx, api, opts.scheduler);
209
+ const it = mountInstanceTracking(ctx, api, scheduler);
201
210
  mounted.push(it);
202
211
  describe.instanceTracking = it.describe?.();
203
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.71.0",
3
+ "version": "0.72.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",
@@ -101,7 +101,7 @@
101
101
  "dependencies": {
102
102
  "@nanobpm/nano-app-schema": "^0.9.0",
103
103
  "@nanobpm/nano-sdk": "^1.2.5",
104
- "@nanobpm/workflow": "^0.12.0",
104
+ "@nanobpm/workflow": "^0.13.0",
105
105
  "bpmn-auto-layout": "^2.0.0-alpha.2",
106
106
  "create-urban-app": "^0.15.0",
107
107
  "yaml": "^2.9.0"
@@ -0,0 +1,89 @@
1
+ // Slice S7 (adversarial) — SCHEMA / vocabulary DRIFT guards + conformance.
2
+ //
3
+ // The shared conformance corpus (S2) is the contract BOTH producer and consumer
4
+ // are held to. Here we (a) hold the producer's own validator to the full corpus,
5
+ // and (b) attack the controlled vocabulary directly — drifted scope/mode/
6
+ // provenance/authority values, and structurally malformed records, must all be
7
+ // rejected with clear, structured errors.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { test } from "node:test";
11
+ import {
12
+ assertConformance,
13
+ corpus,
14
+ fromResultValidator,
15
+ invalidFixtures,
16
+ runConformance,
17
+ validFixtures,
18
+ } from "../conformance/index.ts";
19
+ import { isMemoryRecord, validateMemoryRecord } from "../schema/index.ts";
20
+
21
+ test("the producer validator passes the FULL conformance corpus", () => {
22
+ // Result-form validator, adapted to the corpus's { ok } contract.
23
+ assert.doesNotThrow(() => assertConformance(fromResultValidator(validateMemoryRecord)));
24
+
25
+ const report = runConformance(fromResultValidator(validateMemoryRecord));
26
+ assert.equal(report.failed, 0, "no corpus case may fail");
27
+ assert.equal(report.total, corpus.length);
28
+ assert.ok(report.total > 0);
29
+ });
30
+
31
+ test("the boolean type-guard validator also conforms", () => {
32
+ const report = runConformance((input) => ({ ok: isMemoryRecord(input) }));
33
+ assert.equal(report.failed, 0);
34
+ });
35
+
36
+ test("every valid fixture is accepted and every invalid fixture is rejected", () => {
37
+ for (const c of validFixtures) {
38
+ assert.equal(validateMemoryRecord(c.input).ok, true, `should accept: ${c.name}`);
39
+ }
40
+ for (const c of invalidFixtures) {
41
+ assert.equal(validateMemoryRecord(c.input).ok, false, `should reject: ${c.name}`);
42
+ }
43
+ });
44
+
45
+ test("drifted vocabulary is rejected with the right structured code", () => {
46
+ const drifts: Array<{ field: string; value: string }> = [
47
+ { field: "scope", value: "galaxy" },
48
+ { field: "mode", value: "prescriptive" },
49
+ { field: "provenance", value: "robot" },
50
+ { field: "authority", value: "supreme" },
51
+ ];
52
+ for (const { field, value } of drifts) {
53
+ const input: Record<string, unknown> = {
54
+ schemaVersion: 1,
55
+ id: "x",
56
+ scope: "repo",
57
+ mode: "empirical",
58
+ provenance: "human",
59
+ authority: "authoritative",
60
+ statement: "s",
61
+ createdAt: "2026-01-01T00:00:00Z",
62
+ };
63
+ input[field] = value;
64
+ const result = validateMemoryRecord(input);
65
+ assert.equal(result.ok, false, `drifted ${field}=${value} must be rejected`);
66
+ if (!result.ok) {
67
+ assert.equal(
68
+ result.errors.some((e) => e.path === field && e.code === "invalid-vocabulary"),
69
+ true,
70
+ `expected invalid-vocabulary on ${field}`,
71
+ );
72
+ }
73
+ }
74
+ });
75
+
76
+ test("hostile inputs never crash the validator (throwing getters, exotic shapes)", () => {
77
+ const hostile = {
78
+ get id() {
79
+ throw new Error("boom");
80
+ },
81
+ };
82
+ // Must return a structured rejection, not propagate the throw.
83
+ assert.doesNotThrow(() => validateMemoryRecord(hostile));
84
+ assert.equal(validateMemoryRecord(hostile).ok, false);
85
+
86
+ for (const value of [42, null, undefined, "string", [], new Date()]) {
87
+ assert.equal(validateMemoryRecord(value).ok, false);
88
+ }
89
+ });
@@ -0,0 +1,97 @@
1
+ // Slice S7 (adversarial) — GOVERNANCE can't be bypassed.
2
+ //
3
+ // A proposed prior is a bot "PR": it lives on its own branch and is NOT on the
4
+ // authoritative line until MERGED (ratified). These tests prove an unmerged
5
+ // proposal never acts as a ratified one, and that the ratify path refuses to be
6
+ // steered onto an arbitrary branch or base.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import { ContextWriter, GovernanceError } from "../git/index.ts";
11
+ import { ContextRetriever } from "../retrieval/index.ts";
12
+ import { cleanup, git, makeSubstrate, rec } from "./harness.ts";
13
+
14
+ const TEMP_ROOTS: string[] = [];
15
+ after(() => cleanup(TEMP_ROOTS));
16
+
17
+ function retro(id: string) {
18
+ return rec({
19
+ id,
20
+ provenance: "agent-retro",
21
+ mode: "normative",
22
+ authority: "hypothesis",
23
+ statement: `proposed prior ${id}`,
24
+ });
25
+ }
26
+
27
+ test("an UNMERGED proposal is invisible on the authoritative line", async () => {
28
+ const dir = await makeSubstrate(TEMP_ROOTS);
29
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
30
+
31
+ const proposal = await writer.proposePrior(retro("gov-1"));
32
+ assert.equal(proposal.ratified, false);
33
+
34
+ // Before ratifying, a reader on main sees nothing — the hypothesis is parked
35
+ // on its own branch, not on the base line.
36
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
37
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), false);
38
+
39
+ // Only the merge ratifies it onto main.
40
+ await writer.ratify(proposal);
41
+ retriever.invalidate();
42
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), true);
43
+ });
44
+
45
+ test("ratify refuses a branch outside the proposal namespace", async () => {
46
+ const dir = await makeSubstrate(TEMP_ROOTS);
47
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
48
+
49
+ // A real, existing branch that is NOT a proposal branch.
50
+ await git(dir, "branch", "sneaky-branch", "main");
51
+
52
+ await assert.rejects(
53
+ () =>
54
+ writer.ratify({
55
+ path: "whatever",
56
+ commit: "deadbeef",
57
+ branch: "sneaky-branch",
58
+ baseBranch: "main",
59
+ proposalId: "forged",
60
+ ratified: false,
61
+ }),
62
+ GovernanceError,
63
+ );
64
+ });
65
+
66
+ test("ratify refuses a proposal whose base was steered off the writer's own base", async () => {
67
+ const dir = await makeSubstrate(TEMP_ROOTS);
68
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
69
+
70
+ const proposal = await writer.proposePrior(retro("gov-steer"));
71
+ // ProposalResult is a plain object; a hostile caller mutates the target base.
72
+ await assert.rejects(
73
+ () => writer.ratify({ ...proposal, baseBranch: "some-other-branch" }),
74
+ GovernanceError,
75
+ );
76
+
77
+ // The proposal is still unratified: main must NOT contain it.
78
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
79
+ assert.equal((await retriever.all()).some((s) => s.record.id === "gov-steer"), false);
80
+ });
81
+
82
+ test("ratify refuses a non-existent proposal branch", async () => {
83
+ const dir = await makeSubstrate(TEMP_ROOTS);
84
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
85
+ await assert.rejects(
86
+ () =>
87
+ writer.ratify({
88
+ path: "p",
89
+ commit: "c",
90
+ branch: "context/proposal/does-not-exist",
91
+ baseBranch: "main",
92
+ proposalId: "ghost",
93
+ ratified: false,
94
+ }),
95
+ GovernanceError,
96
+ );
97
+ });
@@ -0,0 +1,84 @@
1
+ // Slice S7 (adversarial) — shared test harness.
2
+ //
3
+ // This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
4
+ // and NOT part of the published surface (the top barrel never re-exports the
5
+ // adversarial slice). It only provides the local temp-git-substrate helpers the
6
+ // adversarial suites drive S3's REAL write path through. Nothing here touches the
7
+ // network or any external service.
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { hardenedGitArgs } from "../binding/git-backend.ts";
15
+ import type { MemoryRecord } from "../schema/index.ts";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Run a git command in `cwd`, returning trimmed stdout. Every invocation is
21
+ * routed through {@link hardenedGitArgs} (the repo's single source of truth for
22
+ * `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
23
+ * hook from global templates or local config — keeping the harness deterministic
24
+ * and closing the CI code-execution vector.
25
+ */
26
+ export async function git(cwd: string, ...args: string[]): Promise<string> {
27
+ const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
28
+ return stdout.trim();
29
+ }
30
+
31
+ /**
32
+ * Create a fresh temp git repo with a single seed commit on `main`, register it
33
+ * for cleanup in `roots`, and return its absolute path. This stands in for the
34
+ * local working copy S1 resolves a binding to — the substrate S3 writes and S4
35
+ * reads.
36
+ */
37
+ export async function makeSubstrate(roots: string[]): Promise<string> {
38
+ const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
39
+ roots.push(dir);
40
+ await git(dir, "init");
41
+ await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
42
+ await git(dir, "add", "-A");
43
+ await git(
44
+ dir,
45
+ "-c",
46
+ "user.name=seed",
47
+ "-c",
48
+ "user.email=seed@nanobpm.local",
49
+ "commit",
50
+ "--no-gpg-sign",
51
+ "-m",
52
+ "seed",
53
+ );
54
+ // Normalise the default branch name regardless of the host git's default.
55
+ await git(dir, "branch", "-M", "main");
56
+ return dir;
57
+ }
58
+
59
+ /**
60
+ * Best-effort recursive cleanup of every registered temp root. Per-root failures
61
+ * (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
62
+ * otherwise-correct suite.
63
+ */
64
+ export async function cleanup(roots: readonly string[]): Promise<void> {
65
+ await Promise.all(
66
+ roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
67
+ );
68
+ }
69
+
70
+ /** A well-formed measured/authoritative record, with per-test overrides. */
71
+ export function rec(overrides: Partial<MemoryRecord> = {}): MemoryRecord {
72
+ const base: MemoryRecord = {
73
+ schemaVersion: 1,
74
+ id: "rec-1",
75
+ scope: "epic",
76
+ scopeRef: "issue-303",
77
+ mode: "empirical",
78
+ provenance: "measured",
79
+ authority: "authoritative",
80
+ statement: "the measured throughput was 42 rps",
81
+ createdAt: "2026-01-01T00:00:00Z",
82
+ };
83
+ return { ...base, ...overrides };
84
+ }