@lmzhen/dsh-evolution-approval 0.17.1 → 0.18.1

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/README.md CHANGED
@@ -59,6 +59,8 @@ preview, or a resolver that cannot answer (a stale anchor, an operation with no
59
59
  - `approve()` dedupes inside one process and providers resolve the record atomically, but the replay runner executes BEFORE that resolution: two OS processes approving one id can both write while one wins the audit transition. Run approvals from one writer process, or make runners idempotent.
60
60
  - **Approve + reject on one id** are not serialized inside a process: a reject can resolve a still-executing record while the runner completes and the write still lands: verify the write state, or reject only when no approve is in flight.
61
61
 
62
+ - **The HTTP face is loopback-only by design.** The fence is the family's shared loopback check, authoritative on the socket (`X-Forwarded-For` is never trusted), so a deployment that opens the host to a LAN — where the platform's own `/api` answers normally — gets **403** on all four routes from any non-loopback client. Reach the host over loopback (a local browser, or an SSH tunnel). Whether the family should follow the platform's `trustedHosts` instead is an open decision (v46 `决策 3`; the mechanism lives in `evolution-core`'s `http-routes.ts`).
63
+
62
64
  **Runtime invariant:** No companion is published. The platform auto-assembles nothing and the family mounts no `<pkg>/invariant` cordis row, so a companion here would never execute (v37 S2.1 / I-3).
63
65
 
64
66
  ## Notes and history
@@ -68,4 +70,4 @@ preview, or a resolver that cannot answer (a stale anchor, an operation with no
68
70
  would drift from what the replay writes (the write path owns its own normalization).
69
71
 
70
72
  - **Concurrent approve + reject on the same id (F-204).** Inside one process the dedupe keys are `approve:<id>` / `reject:<id>`, so the two paths are not serialized against each other. When an approve runner is slow, a reject resolves the still-executing record to `rejected` **without holding a claim**; the runner may then complete and the write can still land while the audit history reads `rejected`: the write effect, not the audit verdict, is what actually persists (`写效果以实际为准`). `reject` on an executing record reports this and asks you to verify the write state manually. Only reject a write after confirming no approve is in flight, or verify the write effect manually afterwards.
71
- - **No staged-content freshness re-validation (F-328).** The staged record stores the `args` snapshot captured at request time and replays exactly those args, but does not record a content hash, so `approve()` does **not** re-check whether the on-disk skill/memory the write targets changed since staging. The write is applied as staged regardless. The pending surface (`/evolution pending --detail`) exposes the staged `args` so you can review what will actually be replayed before approving; there is no automatic drift warning if the target changed in the meantime.
73
+ - **No freshness re-validation for kinds WITHOUT an anchor (F-328, corrected).** The staged record stores the `args` snapshot captured at request time and replays exactly those args. Kinds that carry no anchor — **`memory` today** — replay that snapshot as-is, so a target that changed after staging is not re-checked. A skill write is NOT in that class: it carries `staged_from_sha256`, so its replay and its preview refuse a changed target (the per-kind rule above). The pending surface (`/evolution pending --detail`) exposes the staged `args` so you can review what will actually be replayed before approving.
package/lib/index.js CHANGED
@@ -2,9 +2,10 @@ import { Service } from "@deepseek-ai/cordis";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { elapsedSince, isLoopbackRequest, isProcessAlive, parseLockBody, readJsonObject, textDiffFacts, writeJson } from "@lmzhen/dsh-evolution-core";
4
4
  import z from "@deepseek-ai/schemastery";
5
+ import { PENDING_STATUSES } from "@lmzhen/dsh-evolution-state-storage";
5
6
  //#region lib/types/routes.js
6
7
  /**
7
- * The approval HTTP surface: three loopback routes over the write gate's pending window.
8
+ * The approval HTTP surface: four loopback routes over the write gate's pending window.
8
9
  *
9
10
  * Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
10
11
  * (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
@@ -23,17 +24,7 @@ const APPROVAL_ROUTES = {
23
24
  preview: "/api/dsh-evolution/approval/preview"
24
25
  };
25
26
  /**
26
- * The statuses a caller may ask for, in the order the window moves through them. Annotated with the
27
- * storage type so a change to that union fails to compile here instead of drifting silently.
28
- */
29
- const PENDING_STATUSES = [
30
- "pending",
31
- "executing",
32
- "approved",
33
- "rejected"
34
- ];
35
- /**
36
- * Build the three routes over the approval service.
27
+ * Build the four routes over the approval service.
37
28
  * @param approval - the host's approval face (the service instance, or a spec fixture).
38
29
  * @returns the route list for `ctx.webServer.register`.
39
30
  */
@@ -250,10 +241,14 @@ var EvolutionApproval = class extends Service {
250
241
  static inject = ["evolutionState"];
251
242
  static Config = z.object({
252
243
  enabled: z.boolean().default(false),
253
- stageForeground: z.boolean().default(true)
244
+ stageForeground: z.boolean().default(true),
245
+ pendingTtlMs: z.number().min(0).default(0)
254
246
  });
255
247
  enabled;
256
248
  stageForegroundConfig;
249
+ /** Effective TTL in ms; 0 = the window never expires (T4-09/A51). A non-finite or negative value
250
+ * is clamped here because the schema only guards the loader path. */
251
+ pendingTtlMs;
257
252
  /** P3 (v16): public read for staging pre-checks (learning-graph refuses a
258
253
  * stage that no runner could replay only when foreground writes stage at
259
254
  * all). Mirrors `this.stageForeground`. */
@@ -267,6 +262,8 @@ var EvolutionApproval = class extends Service {
267
262
  super(ctx, "evolutionApproval");
268
263
  this.enabled = config.enabled ?? false;
269
264
  this.stageForegroundConfig = config.stageForeground ?? true;
265
+ const ttl = config.pendingTtlMs ?? 0;
266
+ this.pendingTtlMs = Number.isFinite(ttl) && ttl > 0 ? Math.floor(ttl) : 0;
270
267
  this.ctx.inject(["webServer"], (scope) => {
271
268
  scope.effect(() => {
272
269
  const disposers = makeApprovalRoutes(this).map((route) => scope.webServer.register(route));
@@ -360,7 +357,41 @@ var EvolutionApproval = class extends Service {
360
357
  message: "Foreground write allowed."
361
358
  };
362
359
  }
360
+ /** T4-09/A51: the effective expiry of the staged window, 0 = never. Read by the command surface
361
+ * and the doctor so both can say which of the three human-confirmation states is in force. */
362
+ get ttlMs() {
363
+ return this.pendingTtlMs;
364
+ }
365
+ /**
366
+ * Close every staged write that has been waiting longer than the TTL (T4-09/A51).
367
+ *
368
+ * The window had THREE terminal states only — approve, reject, release — all of them human, so an
369
+ * unattended deployment accumulated staged records for ever. Expiry is the missing third state of
370
+ * the human-confirmation triangle ("never pop up and just run" / "wait indefinitely" / "cancel on
371
+ * timeout"), and it is deliberately implemented as the reject side: the record converges through
372
+ * the seam's exactly-once transition to `rejected` with its `resolvedAt` stamped, and the runner
373
+ * is never invoked. NOTHING a human did not approve can execute; the cost of a wrong TTL is a
374
+ * staged write that must be staged again.
375
+ *
376
+ * A sweep, not a timer: every read of the window converges it (the CLI, the settings card and the
377
+ * doctor all read through {@link list}), so the family does not gain a background timer that would
378
+ * have to be unref'd, disposed and reasoned about for process liveness.
379
+ * @returns the ids that were closed by this sweep (empty when the TTL is off or nothing was stale).
380
+ */
381
+ async expireStale() {
382
+ if (this.pendingTtlMs <= 0) return [];
383
+ const cutoff = Date.now() - this.pendingTtlMs;
384
+ const expired = [];
385
+ for (const record of await this.state().listPending("pending")) {
386
+ const stagedAt = Date.parse(record.createdAt);
387
+ if (!Number.isFinite(stagedAt) || stagedAt > cutoff) continue;
388
+ if ((await this.state().tryResolvePending(record.id, "rejected")).applied) expired.push(record.id);
389
+ }
390
+ if (expired.length > 0) this.ctx.logger.warn(`evolution-approval: ${expired.length} staged write(s) passed pendingTtlMs=${this.pendingTtlMs} and were closed as rejected WITHOUT executing: ${expired.join(", ")} — stage them again if they are still wanted`);
391
+ return expired;
392
+ }
363
393
  async list(status = "pending") {
394
+ await this.expireStale();
364
395
  return await this.state().listPending(status);
365
396
  }
366
397
  async approve(id) {
@@ -113,6 +113,10 @@ export type ApprovalLike = {
113
113
  * not pre-refuse on it. */
114
114
  stageForeground?: boolean;
115
115
  registerRunner(kind: PendingKind, runner: WriteRunner, preview?: WritePreview): () => void;
116
+ /** T4-09/A51: the staged window's effective expiry in ms, 0 = never. Optional for the same reason
117
+ * the other reads are: an older/partial implementation simply does not answer, and a caller must not
118
+ * read that absence as "expires immediately". */
119
+ ttlMs?: number;
116
120
  list(status?: PendingStatus): Promise<PendingRecord[]>;
117
121
  approve(id: string): Promise<{
118
122
  ok: boolean;
@@ -149,12 +153,22 @@ export interface Config {
149
153
  enabled?: boolean;
150
154
  /** Require approval for foreground writes as well. */
151
155
  stageForeground?: boolean;
156
+ /**
157
+ * T4-09/A51: milliseconds after which a STAGED write expires. `0` (the default) means never —
158
+ * the window then behaves exactly as before. Expiry closes the record the same way a reject does
159
+ * (status `rejected`, `resolvedAt` stamped, runner NEVER invoked): an expiry is a cleanup of the
160
+ * window, never an approval, so nothing a human did not approve can execute.
161
+ */
162
+ pendingTtlMs?: number;
152
163
  }
153
164
  export declare class EvolutionApproval extends Service {
154
165
  static inject: string[];
155
166
  static Config: Schema<Config>;
156
167
  private readonly enabled;
157
168
  private readonly stageForegroundConfig;
169
+ /** Effective TTL in ms; 0 = the window never expires (T4-09/A51). A non-finite or negative value
170
+ * is clamped here because the schema only guards the loader path. */
171
+ private readonly pendingTtlMs;
158
172
  /** P3 (v16): public read for staging pre-checks (learning-graph refuses a
159
173
  * stage that no runner could replay only when foreground writes stage at
160
174
  * all). Mirrors `this.stageForeground`. */
@@ -192,6 +206,26 @@ export declare class EvolutionApproval extends Service {
192
206
  }>;
193
207
  /** Evaluate one mutation. Returns allow, or stores the write and returns staged. */
194
208
  request(input: ApprovalRequest): Promise<ApprovalDecision>;
209
+ /** T4-09/A51: the effective expiry of the staged window, 0 = never. Read by the command surface
210
+ * and the doctor so both can say which of the three human-confirmation states is in force. */
211
+ get ttlMs(): number;
212
+ /**
213
+ * Close every staged write that has been waiting longer than the TTL (T4-09/A51).
214
+ *
215
+ * The window had THREE terminal states only — approve, reject, release — all of them human, so an
216
+ * unattended deployment accumulated staged records for ever. Expiry is the missing third state of
217
+ * the human-confirmation triangle ("never pop up and just run" / "wait indefinitely" / "cancel on
218
+ * timeout"), and it is deliberately implemented as the reject side: the record converges through
219
+ * the seam's exactly-once transition to `rejected` with its `resolvedAt` stamped, and the runner
220
+ * is never invoked. NOTHING a human did not approve can execute; the cost of a wrong TTL is a
221
+ * staged write that must be staged again.
222
+ *
223
+ * A sweep, not a timer: every read of the window converges it (the CLI, the settings card and the
224
+ * doctor all read through {@link list}), so the family does not gain a background timer that would
225
+ * have to be unref'd, disposed and reasoned about for process liveness.
226
+ * @returns the ids that were closed by this sweep (empty when the TTL is off or nothing was stale).
227
+ */
228
+ expireStale(): Promise<string[]>;
195
229
  list(status?: PendingStatus): Promise<PendingRecord[]>;
196
230
  approve(id: string): Promise<{
197
231
  ok: boolean;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The approval HTTP surface: three loopback routes over the write gate's pending window.
2
+ * The approval HTTP surface: four loopback routes over the write gate's pending window.
3
3
  *
4
4
  * Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
5
5
  * (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
@@ -53,7 +53,7 @@ export interface ApprovalFace {
53
53
  previewOf(kind: PendingKind): WritePreview | undefined;
54
54
  }
55
55
  /**
56
- * Build the three routes over the approval service.
56
+ * Build the four routes over the approval service.
57
57
  * @param approval - the host's approval face (the service instance, or a spec fixture).
58
58
  * @returns the route list for `ctx.webServer.register`.
59
59
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-approval",
3
3
  "description": "Stage/pending approval service for Hermes-style self-evolution writes (community build)",
4
- "version": "0.17.1",
4
+ "version": "0.18.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -26,19 +26,19 @@
26
26
  ],
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-core": "^0.17.1"
29
+ "@deepseek-ai/schemastery": "^3.18.4",
30
+ "@lmzhen/dsh-evolution-core": "^0.18.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@deepseek-ai/cordis": "^4.0.1",
34
34
  "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
35
- "@lmzhen/dsh-evolution-state-storage": "^0.17.1",
36
- "@lmzhen/dsh-evolution-state": "^0.17.1"
35
+ "@lmzhen/dsh-evolution-state-storage": "^0.18.1",
36
+ "@lmzhen/dsh-evolution-state": "^0.18.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
40
- "@lmzhen/dsh-evolution-core": "^0.17.1",
41
- "@lmzhen/dsh-evolution-state-storage": "^0.17.1",
42
- "@lmzhen/dsh-evolution-state": "^0.17.1"
40
+ "@lmzhen/dsh-evolution-core": "^0.18.1",
41
+ "@lmzhen/dsh-evolution-state-storage": "^0.18.1",
42
+ "@lmzhen/dsh-evolution-state": "^0.18.1"
43
43
  }
44
44
  }