@dsh-enhanced/assistant-evolution 0.1.6 → 0.1.12

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/lib/service.js CHANGED
@@ -1,14 +1,15 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { Context, Service } from '@deepseek-ai/cordis';
4
4
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
5
5
  import Schema from '@deepseek-ai/schemastery';
6
+ import { canonicalEvaluationHostScope, evaluationLearningProjectionDigest, } from '@dsh-enhanced/assistant-evaluation';
6
7
  import { ApprovalSettlementConflict, validateApprovalSettlement, } from '@dsh-enhanced/assistant-policy';
7
8
  import { buildGuidance, buildGuidanceSnapshot } from './guidance.js';
8
9
  import { evolutionMutationReview } from './review.js';
9
- import { EvolutionStore, EvolutionStoreError, evolutionDigest } from './store.js';
10
+ import { EvolutionStore, EvolutionStoreError, evolutionDigest, supervisedGrowthAnalystProposalIdempotencyKey, } from './store.js';
10
11
  import { registerEvolutionTools } from './tools.js';
11
- import { legacyEvolutionScope } from './types.js';
12
+ import { legacyEvolutionScope, SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION, } from './types.js';
12
13
  const configSchema = Schema.object({
13
14
  databasePath: Schema.string().required(),
14
15
  evaluationWindow: Schema.number().step(1).min(1).max(10_000).default(20),
@@ -23,8 +24,20 @@ const configSchema = Schema.object({
23
24
  defaultProposalTtlMs: Schema.number().step(1).min(1).default(900_000),
24
25
  reconcileIntervalMs: Schema.number().step(1).min(0).default(15_000),
25
26
  reconcileLimit: Schema.number().step(1).min(1).max(1_000).default(50),
27
+ autonomousRollback: Schema.boolean().default(false),
26
28
  });
27
29
  const evolutionApprovalSource = 'dsh-enhanced-assistant-evolution';
30
+ function registrationOwnedByDeliveryPresentation(registration) {
31
+ const candidate = registration;
32
+ try {
33
+ return typeof candidate.owner === 'object' && candidate.owner !== null
34
+ && typeof candidate.owner.ownsTrustedDeliveryPresentationRegistration === 'function'
35
+ && candidate.owner.ownsTrustedDeliveryPresentationRegistration(registration);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
28
41
  export class AssistantEvolutionError extends Error {
29
42
  code;
30
43
  constructor(code, message) {
@@ -33,6 +46,51 @@ export class AssistantEvolutionError extends Error {
33
46
  this.name = 'AssistantEvolutionError';
34
47
  }
35
48
  }
49
+ /** Fixed Policy subject used by the non-model supervised-growth runbook. */
50
+ export const HOST_RECOVERY_BACKGROUND_ID = 'dsh-enhanced-assistant-recovery';
51
+ /** The only Automation allowed to use the model-visible analyst capability. */
52
+ export const SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID = 'heartbeat:supervised-growth-analyst';
53
+ const evolutionHostScopeBrand = Symbol('assistant-evolution.host-scope');
54
+ /** Canonical, immutable and intentionally non-serializable Host scope token. */
55
+ export function canonicalEvolutionHostScope(input) {
56
+ const scopeKey = canonicalEvolutionScope(input.workspace, input.preset);
57
+ const [workspace, preset] = JSON.parse(scopeKey);
58
+ const branded = { workspace, preset };
59
+ Object.defineProperty(branded, evolutionHostScopeBrand, {
60
+ value: true, enumerable: false, configurable: false, writable: false,
61
+ });
62
+ return Object.freeze(branded);
63
+ }
64
+ function exactEvolutionHostScope(input) {
65
+ if (typeof input !== 'object' || input === null || !Object.isFrozen(input)
66
+ || input[evolutionHostScopeBrand] !== true) {
67
+ throw new AssistantEvolutionError('invalid-input', 'Host evolution operations require a canonical immutable scope');
68
+ }
69
+ const canonical = canonicalEvolutionHostScope(input);
70
+ if (canonical.workspace !== input.workspace || canonical.preset !== input.preset) {
71
+ throw new AssistantEvolutionError('invalid-input', 'Host evolution scope is not canonical');
72
+ }
73
+ return input;
74
+ }
75
+ function hostText(value, label, maxBytes) {
76
+ if (typeof value !== 'string') {
77
+ throw new AssistantEvolutionError('invalid-input', `${label} must be a string`);
78
+ }
79
+ const normalized = value.normalize('NFC').trim();
80
+ if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maxBytes
81
+ || hasControl(normalized)) {
82
+ throw new AssistantEvolutionError('invalid-input', `${label} is invalid`);
83
+ }
84
+ return normalized;
85
+ }
86
+ function hasControl(value) {
87
+ for (const character of value) {
88
+ const codePoint = character.codePointAt(0);
89
+ if (codePoint <= 0x1f || codePoint === 0x7f)
90
+ return true;
91
+ }
92
+ return false;
93
+ }
36
94
  export function canonicalEvolutionScope(workspace, preset) {
37
95
  const normalizedPreset = preset.normalize('NFC').trim();
38
96
  if (!isAbsolute(workspace) || normalizedPreset === '') {
@@ -63,24 +121,30 @@ function ruleIdFromStableMutation(stable) {
63
121
  * only then let an approved rule shape future sessions as injected advisory
64
122
  * context. Three boundaries are structural rather than cosmetic:
65
123
  *
66
- * - **No self-approval.** Every rule change is an `assistant-policy` proposal. The
67
- * service cannot decide its own proposals, so the assistant can never grant
68
- * itself a new behaviour.
124
+ * - **No self-adoption.** New or changed guidance is always an
125
+ * `assistant-policy` proposal. The only optional autonomous mutation removes
126
+ * an exact active rule after Host-recomputed regression evidence; it can never
127
+ * create or revise guidance.
69
128
  * - **No privilege growth.** Guidance is injected as data and policy never reads
70
129
  * the rule table, so a rule can change approach but never authority.
71
- * - **No in-place revision.** Changing behaviour is retire-then-adopt, each
72
- * separately approved, so an old approval can never silently cover new guidance.
130
+ * - **No in-place revision.** Replacement is always retire-then-adopt. A rollback
131
+ * may remove the old rule, but the replacement still needs its own approval,
132
+ * so an old decision can never silently cover new guidance.
73
133
  */
74
134
  export class AssistantEvolutionService extends Service {
75
135
  static Config = configSchema;
76
- static inject = ['assistantPolicy'];
136
+ static inject = ['assistantPolicy', 'assistantEvaluation'];
77
137
  store;
78
138
  policy;
139
+ context;
79
140
  config;
80
- injected = new WeakSet();
141
+ now;
81
142
  delivery;
143
+ presentationProducerGeneration = `assistant-evolution-presentation:${randomUUID()}`;
144
+ presentationSink;
82
145
  active = true;
83
- constructor(ctx, input) {
146
+ lastReconciledAt = 0;
147
+ constructor(ctx, input, options = {}) {
84
148
  super(ctx, 'assistantEvolution');
85
149
  try {
86
150
  this.config = configSchema(input);
@@ -91,7 +155,9 @@ export class AssistantEvolutionService extends Service {
91
155
  const policy = ctx.get('assistantPolicy');
92
156
  if (policy === undefined)
93
157
  throw new Error('assistant-evolution: assistantPolicy service is required');
158
+ this.context = ctx;
94
159
  this.policy = policy;
160
+ this.now = options.now ?? Date.now;
95
161
  this.store = new EvolutionStore({
96
162
  path: this.config.databasePath,
97
163
  maxGuidanceBytes: this.config.maxRuleGuidanceBytes,
@@ -99,6 +165,7 @@ export class AssistantEvolutionService extends Service {
99
165
  ctx.inject(['assistantDelivery'], deliveryCtx => {
100
166
  const delivery = deliveryCtx.get('assistantDelivery');
101
167
  this.delivery = delivery;
168
+ this.reconcileApplicationPresentations();
102
169
  return () => {
103
170
  if (this.delivery === delivery)
104
171
  this.delivery = undefined;
@@ -118,6 +185,7 @@ export class AssistantEvolutionService extends Service {
118
185
  });
119
186
  ctx.effect(() => () => {
120
187
  this.active = false;
188
+ this.presentationSink?.dispose();
121
189
  this.store.close();
122
190
  }, 'assistant-evolution.database');
123
191
  if (this.config.reconcileIntervalMs > 0) {
@@ -135,7 +203,55 @@ export class AssistantEvolutionService extends Service {
135
203
  }, 'assistant-evolution.reconcile');
136
204
  }
137
205
  }
138
- /** Record one observed outcome as evidence. */
206
+ /** Aggregate health seam. It never returns content, identities, scopes, or paths. */
207
+ health() {
208
+ this.assertActive();
209
+ return Object.freeze({
210
+ ...this.store.health(),
211
+ lastReconciledAt: this.lastReconciledAt,
212
+ });
213
+ }
214
+ trustedDeliveryPresentationProducerGeneration() {
215
+ this.assertActive();
216
+ return this.presentationProducerGeneration;
217
+ }
218
+ /**
219
+ * Accept only the publisher minted by the exact live Delivery instance.
220
+ * The registration is held privately; it is never surfaced to an Agent,
221
+ * tool schema, proposal payload, or Evolution read API.
222
+ */
223
+ registerTrustedDeliveryPresentationSink(registration) {
224
+ this.assertActive();
225
+ if (registration.protocol !== 'assistant-delivery/trusted-presentation-producer/v1'
226
+ || registration.producer !== 'assistant-evolution'
227
+ || registration.generation !== this.presentationProducerGeneration
228
+ || typeof registration.publish !== 'function'
229
+ || !registrationOwnedByDeliveryPresentation(registration)) {
230
+ throw new AssistantEvolutionError('forbidden', 'trusted Delivery presentation registration is invalid');
231
+ }
232
+ const current = this.presentationSink;
233
+ if (current !== undefined) {
234
+ if (current.registration === registration)
235
+ return current.dispose;
236
+ throw new AssistantEvolutionError('forbidden', 'trusted Delivery presentation sink is already registered');
237
+ }
238
+ const token = Symbol('assistant-evolution.trusted-delivery-presentation');
239
+ let live = true;
240
+ const dispose = () => {
241
+ if (!live)
242
+ return;
243
+ live = false;
244
+ if (this.presentationSink?.token === token)
245
+ this.presentationSink = undefined;
246
+ };
247
+ this.presentationSink = Object.freeze({ token, registration, dispose });
248
+ // Existing durable terminal receipts become eligible as soon as their
249
+ // exact Delivery publisher appears; the local outbox remains authoritative
250
+ // if Delivery rejects or later revokes this generation.
251
+ this.reconcileApplicationPresentations();
252
+ return dispose;
253
+ }
254
+ /** Record one model-reported outcome as learning-ineligible operational audit. */
139
255
  recordEpisode(agent, input) {
140
256
  const scopeKey = this.authorize(agent, 'append', `situation:${input.situation}`);
141
257
  return this.store.recordEpisode({
@@ -145,14 +261,17 @@ export class AssistantEvolutionService extends Service {
145
261
  detail: input.detail,
146
262
  source: 'foreground',
147
263
  trust: 'self-reported',
264
+ evidenceKind: 'operational',
148
265
  ...(input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
149
266
  occurredAt: input.occurredAt,
150
267
  idempotencyKey: input.idempotencyKey,
151
268
  });
152
269
  }
153
270
  /**
154
- * Record an outcome observed by trusted local infrastructure rather than a model
155
- * turn, for example a finished background automation run.
271
+ * Record an execution outcome observed by trusted local infrastructure rather
272
+ * than a model turn, for example a finished background automation run. It is
273
+ * deliberately operational and learning-ineligible: execution success/failure
274
+ * does not establish objective task quality.
156
275
  *
157
276
  * There is no Agent to authorize here, so this is deliberately narrow: it can
158
277
  * only append evidence. It cannot adopt, retire, or read rules, so an automation
@@ -186,11 +305,96 @@ export class AssistantEvolutionService extends Service {
186
305
  scopeKey,
187
306
  source: 'automation',
188
307
  trust: trusted ? 'trusted' : 'legacy',
308
+ evidenceKind: trusted ? 'operational' : 'legacy-unknown',
189
309
  ...(attributed
190
310
  ? { ruleId: receipt.ruleId, guidanceVersion: receipt.guidanceVersion }
191
311
  : input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
192
312
  });
193
313
  }
314
+ /**
315
+ * Deprecated unsafe v5 shape. Free caller-supplied quality fields can never
316
+ * produce learning-eligible evidence again; use projectEvaluationOutcome().
317
+ */
318
+ recordEvaluationOutcome(input) {
319
+ this.assertActive();
320
+ void input;
321
+ throw new AssistantEvolutionError('forbidden', 'free-form Evaluation projection is disabled; use projectEvaluationOutcome with an authoritative receipt');
322
+ }
323
+ /**
324
+ * Project one exact trusted Evaluation row into the learning ledger. The
325
+ * caller cannot supply outcome, detail, kind, time, situation or attribution.
326
+ */
327
+ projectEvaluationOutcome(input) {
328
+ this.assertActive();
329
+ if (typeof input !== 'object' || input === null || Array.isArray(input)
330
+ || Object.keys(input).sort().join(',') !== 'evaluationId,scope') {
331
+ throw new AssistantEvolutionError('invalid-input', 'Evaluation projection accepts only an exact scope and evaluationId');
332
+ }
333
+ const scope = exactEvolutionHostScope(input.scope);
334
+ const evaluationId = hostText(input.evaluationId, 'evaluationId', 200);
335
+ const receipt = this.trustedTaskLearningReceipt(scope, evaluationId);
336
+ const projected = this.applyTrustedTaskLearningReceipt(scope, receipt);
337
+ if (projected.episode === undefined) {
338
+ throw new AssistantEvolutionError('forbidden', 'the canonical task revision retracts rather than upserts a behavioural learning vote');
339
+ }
340
+ return projected.episode;
341
+ }
342
+ /**
343
+ * Narrow structural seam used only by Evaluation's durable outbox. The raw
344
+ * scope is canonicalized here; quality fields are still fetched exclusively
345
+ * from the trusted Evaluation ledger.
346
+ */
347
+ projectTrustedEvaluationOutcome(_input) {
348
+ this.assertActive();
349
+ throw new AssistantEvolutionError('forbidden', 'raw Evaluation projection protocol is retired; use the versioned task-revision capability');
350
+ }
351
+ /** Evaluation protocol v2 sink. Every success echoes the exact applied revision. */
352
+ projectTrustedEvaluationTaskRevision(input) {
353
+ this.assertActive();
354
+ if (typeof input !== 'object' || input === null || Array.isArray(input)
355
+ || Object.keys(input).sort().join(',') !== 'evaluationId,scope'
356
+ || typeof input.scope !== 'object' || input.scope === null || Array.isArray(input.scope)
357
+ || Object.keys(input.scope).sort().join(',') !== 'preset,workspace') {
358
+ throw new AssistantEvolutionError('invalid-input', 'trusted Evaluation projection input is invalid');
359
+ }
360
+ const evaluationId = hostText(input.evaluationId, 'evaluationId', 200);
361
+ const scope = canonicalEvolutionHostScope(input.scope);
362
+ const receipt = this.trustedTaskLearningReceipt(scope, evaluationId);
363
+ const projected = this.applyTrustedTaskLearningReceipt(scope, receipt);
364
+ return Object.freeze({
365
+ triggerOutcomeId: evaluationId,
366
+ subjectKind: receipt.projection.subjectKind,
367
+ subjectRef: receipt.projection.subjectRef,
368
+ version: receipt.projection.version,
369
+ digest: receipt.projection.digest,
370
+ scopeWatermark: receipt.scopeWatermark,
371
+ disposition: receipt.projection.disposition,
372
+ status: projected.replayed ? 'replayed' : 'applied',
373
+ });
374
+ }
375
+ /**
376
+ * Called by Assistant Automations only after it installed the immutable
377
+ * production execution context and bound the Policy initiator. This closes
378
+ * the session-start ordering gap without weakening background Policy.
379
+ */
380
+ injectAutomationGuidance(agent, execution) {
381
+ this.assertActive();
382
+ const current = agent.ctx
383
+ .get('assistantAutomationExecution');
384
+ const installed = current;
385
+ if (typeof current !== 'object' || current === null || Array.isArray(current)
386
+ || Object.keys(current).sort().join(',') !== 'automationId,mode,occurrenceId'
387
+ || installed?.mode !== execution.mode
388
+ || installed.automationId !== execution.automationId
389
+ || installed.occurrenceId !== execution.occurrenceId
390
+ || Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId'
391
+ || execution.mode !== 'production'
392
+ || hostText(execution.automationId, 'automationId', 500) !== execution.automationId
393
+ || hostText(execution.occurrenceId, 'occurrenceId', 500) !== execution.occurrenceId) {
394
+ throw new AssistantEvolutionError('forbidden', 'automation guidance requires the exact installed production execution context');
395
+ }
396
+ this.injectGuidance(agent);
397
+ }
194
398
  /** Query an exact post-injection receipt for trusted automation attribution. */
195
399
  async captureAutomationExposure(input) {
196
400
  this.assertActive();
@@ -207,20 +411,271 @@ export class AssistantEvolutionService extends Service {
207
411
  /** Candidate rule changes implied by current evidence. Never auto-applied. */
208
412
  candidates(agent) {
209
413
  const scopeKey = this.authorize(agent, 'inspect', 'candidates');
210
- return this.store.candidates({
211
- scopeKey,
212
- window: this.config.evaluationWindow,
213
- minSample: this.config.minSample,
214
- adoptFailureRate: this.config.adoptFailureRate,
215
- retireFailureRate: this.config.retireFailureRate,
216
- limit: this.config.maxCandidates,
217
- evidenceSampleLimit: this.config.maxEvidenceSamples,
414
+ return this.candidatesForScope(scopeKey);
415
+ }
416
+ /** Agent-free, exact-scope candidate inspection for the fixed Host runbook. */
417
+ hostCandidates(input) {
418
+ const scopeKey = this.authorizeHost(input, 'inspect', 'candidates');
419
+ return this.candidatesForScope(scopeKey);
420
+ }
421
+ /**
422
+ * Review exactly one adoption candidate from the dedicated production
423
+ * analyst. The random review token is durable and bound to this occurrence;
424
+ * no foreground or preview session can obtain one.
425
+ */
426
+ reviewSupervisedGrowthAdoption(agent) {
427
+ const execution = this.authorizeSupervisedGrowthAnalyst(agent, 'inspect', 'analyst-adoption');
428
+ // Even review requires the route that a later proposal will use. This
429
+ // prevents an analyst with no owner destination from minting stale tokens.
430
+ this.approvalRoute(agent, undefined);
431
+ const candidate = this.candidatesForScope(execution.scopeKey)
432
+ .filter(entry => entry.kind === 'adopt')
433
+ .sort((left, right) => {
434
+ const rateOrder = right.stats.failures * left.stats.total
435
+ - left.stats.failures * right.stats.total;
436
+ return rateOrder || right.stats.failures - left.stats.failures
437
+ || right.stats.total - left.stats.total
438
+ || left.situation.localeCompare(right.situation);
439
+ })[0];
440
+ if (candidate === undefined) {
441
+ return Object.freeze({ contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION });
442
+ }
443
+ const frozen = this.store.registerSupervisedGrowthAnalystReview({
444
+ scopeKey: execution.scopeKey,
445
+ occurrenceId: execution.occurrenceId,
446
+ candidate,
447
+ evidenceWindow: this.config.evaluationWindow,
448
+ });
449
+ return Object.freeze({
450
+ contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION,
451
+ candidate: Object.freeze({
452
+ ...frozen.review.evidence,
453
+ reviewToken: frozen.review.reviewToken,
454
+ proposalExists: frozen.proposalExists,
455
+ }),
218
456
  });
219
457
  }
458
+ /**
459
+ * Turn one frozen analyst review into the normal owner approval. Identity is
460
+ * evidence-derived; wording changes and concurrent executions join the first
461
+ * durable proposal instead of producing several cards.
462
+ */
463
+ proposeSupervisedGrowthAdoption(agent, input) {
464
+ if (typeof input !== 'object' || input === null || Array.isArray(input)
465
+ || Object.keys(input).sort().join(',') !== 'guidance,reviewToken') {
466
+ throw new AssistantEvolutionError('invalid-input', 'analyst proposal accepts only reviewToken and guidance');
467
+ }
468
+ const execution = this.authorizeSupervisedGrowthAnalyst(agent, 'propose', 'proposals');
469
+ const route = this.approvalRoute(agent, undefined);
470
+ const reviewToken = hostText(input.reviewToken, 'reviewToken', 200);
471
+ const review = this.store.getSupervisedGrowthAnalystReview(reviewToken);
472
+ if (review === undefined || review.scopeKey !== execution.scopeKey
473
+ || review.occurrenceId !== execution.occurrenceId) {
474
+ throw new AssistantEvolutionError('not-found', 'analyst review token does not belong to this production execution');
475
+ }
476
+ const evidence = review.evidence;
477
+ if (evidence.contractVersion !== SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION
478
+ || evidence.total < this.config.minSample
479
+ || evidence.failures / evidence.total < this.config.adoptFailureRate) {
480
+ throw new AssistantEvolutionError('forbidden', 'analyst review no longer satisfies adoption thresholds');
481
+ }
482
+ const idempotencyKey = supervisedGrowthAnalystProposalIdempotencyKey({
483
+ scopeKey: execution.scopeKey,
484
+ situation: evidence.situation,
485
+ evidenceDigest: evidence.evidenceDigest,
486
+ evidenceTotal: evidence.evidenceTotal,
487
+ contractVersion: evidence.contractVersion,
488
+ });
489
+ const ruleId = ruleIdFromStableMutation(evolutionDigest([
490
+ 'supervised-growth-analyst-rule/v1',
491
+ idempotencyKey,
492
+ ]));
493
+ const mutation = {
494
+ op: 'adopt',
495
+ ruleId,
496
+ input: {
497
+ scopeKey: execution.scopeKey,
498
+ situation: evidence.situation,
499
+ guidance: input.guidance,
500
+ },
501
+ baseline: {
502
+ scopeKey: execution.scopeKey,
503
+ situation: evidence.situation,
504
+ failures: evidence.failures,
505
+ total: evidence.total,
506
+ },
507
+ evidence: {
508
+ sampleEpisodeIds: evidence.sampleEpisodeIds,
509
+ digest: evidence.evidenceDigest,
510
+ total: evidence.evidenceTotal,
511
+ window: evidence.evidenceWindow,
512
+ scopeWatermark: evidence.scopeWatermark,
513
+ taskRevisions: evidence.taskRevisions,
514
+ },
515
+ };
516
+ const ttlMs = this.config.defaultProposalTtlMs;
517
+ const requester = `automation:${SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID}`;
518
+ const reviewPresentation = evolutionMutationReview(mutation);
519
+ const creationIntent = {
520
+ idempotencyKey,
521
+ requester,
522
+ principal: route.principal,
523
+ action: reviewPresentation.action,
524
+ resource: reviewPresentation.resource,
525
+ diff: reviewPresentation.diff,
526
+ summary: reviewPresentation.summary,
527
+ ttlMs,
528
+ ...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
529
+ };
530
+ const local = this.withLearningWriterFence(mutation, () => (this.store.createSupervisedGrowthAnalystProposal({
531
+ reviewToken,
532
+ scopeKey: execution.scopeKey,
533
+ occurrenceId: execution.occurrenceId,
534
+ requester,
535
+ principal: route.principal,
536
+ mutation,
537
+ expiresAt: Date.now() + ttlMs,
538
+ creationIntent,
539
+ })));
540
+ if (local.proposal.policyProposalId !== undefined) {
541
+ if (local.proposal.status !== 'pending') {
542
+ return result(local.proposal, true, local.proposal.resultRuleId === undefined
543
+ ? undefined
544
+ : this.store.getRule(local.proposal.resultRuleId));
545
+ }
546
+ return this.settleAttached(local.proposal, true) ?? result(local.proposal, true);
547
+ }
548
+ return this.submitLocalProposal(local.proposal, local.replayed);
549
+ }
220
550
  listRules(agent, status) {
221
551
  const scopeKey = this.authorize(agent, 'inspect', 'rules');
222
552
  return this.store.listRules(scopeKey, status);
223
553
  }
554
+ /** Agent-free, exact-scope rule inspection for the fixed Host runbook. */
555
+ hostListRules(input) {
556
+ const scopeKey = this.authorizeHost(input, 'inspect', `rules:${input.status ?? 'all'}`);
557
+ return this.store.listRules(scopeKey, input.status);
558
+ }
559
+ /**
560
+ * Remove one exact active guidance generation through the opt-in low-risk lane.
561
+ *
562
+ * The model identifies only the immutable rule and its observed version. This
563
+ * method derives scope from the Agent, authorizes the exact Policy action, and
564
+ * delegates the evidence/risk/reason decision to one transactional Host path.
565
+ */
566
+ rollback(agent, input) {
567
+ const normalized = this.normalizedRollbackInput(input);
568
+ const scopeKey = this.authorize(agent, 'rollback', `rule:${normalized.ruleId}`);
569
+ return this.rollbackForScope(scopeKey, normalized);
570
+ }
571
+ /**
572
+ * Retire one exact active rule by CAS. The Store admits only quality-eligible
573
+ * objective/verification evidence attributed to an exact exposure receipt;
574
+ * operational automation outcomes cannot satisfy this lane.
575
+ */
576
+ hostRollbackOne(input) {
577
+ const normalized = this.normalizedRollbackInput(input);
578
+ const scopeKey = this.authorizeHost(input, 'rollback', `rule:${normalized.ruleId}`);
579
+ return this.rollbackForScope(scopeKey, normalized);
580
+ }
581
+ /**
582
+ * Ask the authenticated owner to remove one exact active guidance version.
583
+ *
584
+ * This is intentionally a proposal, not a direct rollback: the current Agent
585
+ * supplies the exact workspace/preset scope, Delivery supplies the owner
586
+ * principal, and Policy freezes a distinct `evolution.owner-undo` review
587
+ * tuple. Approval can only retire the rule; it cannot revise its guidance or
588
+ * grant authority. No regression sample is required because the owner is the
589
+ * authority withdrawing their own earlier approval.
590
+ */
591
+ requestOwnerUndo(agent, input) {
592
+ this.assertActive();
593
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
594
+ throw new AssistantEvolutionError('invalid-input', 'owner undo input must be an object');
595
+ }
596
+ const keys = Object.keys(input).sort().join(',');
597
+ if (keys !== 'expectedVersion,operationId,ruleId'
598
+ && keys !== 'expectedVersion,operationId,ruleId,ttlMs') {
599
+ throw new AssistantEvolutionError('invalid-input', 'owner undo accepts only ruleId, expectedVersion, operationId, and optional ttlMs');
600
+ }
601
+ const scopeKey = this.authorize(agent, 'propose', 'proposals');
602
+ const ruleId = hostText(input.ruleId, 'ruleId', 200);
603
+ if (!/^rule-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
604
+ .test(ruleId)) {
605
+ throw new AssistantEvolutionError('invalid-input', 'ruleId must be an immutable server-issued rule ID');
606
+ }
607
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1
608
+ || input.expectedVersion > 1_000_000_000) {
609
+ throw new AssistantEvolutionError('invalid-input', 'expectedVersion must be a positive safe integer');
610
+ }
611
+ const operationId = hostText(input.operationId, 'operationId', 500);
612
+ const ttlMs = input.ttlMs ?? this.config.defaultProposalTtlMs;
613
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
614
+ throw new AssistantEvolutionError('invalid-input', 'ttlMs must be a positive safe integer');
615
+ }
616
+ const targetRule = this.store.getRule(ruleId);
617
+ if (targetRule === undefined || targetRule.scopeKey !== scopeKey) {
618
+ throw new AssistantEvolutionError('not-found', 'evolution rule was not found in the Agent scope');
619
+ }
620
+ const route = this.approvalRoute(agent, undefined);
621
+ const requester = `agent:${agent.session.header.agentPreset}:owner-undo`;
622
+ const mutation = {
623
+ op: 'owner-undo',
624
+ scopeKey,
625
+ ruleId,
626
+ situation: targetRule.situation,
627
+ guidance: targetRule.guidance,
628
+ generation: targetRule.generation,
629
+ expectedVersion: input.expectedVersion,
630
+ reason: 'Owner-approved immediate guidance undo.',
631
+ };
632
+ // operationId is the caller's retry identity. Deliberately exclude the
633
+ // target tuple from this key so reusing one operation for another target is
634
+ // detected as an idempotency conflict instead of creating a second card.
635
+ const stable = evolutionDigest(['owner-undo-v1', scopeKey, route.principal, operationId]);
636
+ const idempotencyKey = `evolution-owner-undo:${stable}`;
637
+ const review = evolutionMutationReview(mutation);
638
+ const creationIntent = {
639
+ idempotencyKey,
640
+ requester,
641
+ principal: route.principal,
642
+ action: review.action,
643
+ resource: review.resource,
644
+ diff: review.diff,
645
+ summary: review.summary,
646
+ ttlMs,
647
+ ...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
648
+ };
649
+ const prior = this.store.getProposalByIdempotencyKey(idempotencyKey);
650
+ if (prior === undefined) {
651
+ if (targetRule.status !== 'active') {
652
+ throw new AssistantEvolutionError('not-found', 'active evolution rule was not found in the Agent scope');
653
+ }
654
+ if (targetRule.version !== input.expectedVersion) {
655
+ throw new AssistantEvolutionError('invalid-input', 'owner undo expectedVersion is stale');
656
+ }
657
+ if (this.store.ruleApprovalPrincipal(ruleId) !== route.principal) {
658
+ throw new AssistantEvolutionError('forbidden', 'owner undo route does not match the principal who approved this guidance');
659
+ }
660
+ }
661
+ const local = this.store.createProposal({
662
+ idempotencyKey,
663
+ requester,
664
+ principal: route.principal,
665
+ mutation,
666
+ // Policy proposal expiry uses the wall clock; the injectable clock is
667
+ // reserved for deterministic ledger/audit timestamps.
668
+ expiresAt: Date.now() + ttlMs,
669
+ creationIntent,
670
+ });
671
+ if (local.policyProposalId !== undefined) {
672
+ if (local.status !== 'pending') {
673
+ return result(local, true, local.resultRuleId === undefined ? undefined : this.store.getRule(local.resultRuleId));
674
+ }
675
+ return this.settleAttached(local, true) ?? result(local, true);
676
+ }
677
+ return this.submitLocalProposal(local, false);
678
+ }
224
679
  /**
225
680
  * Propose adopting or retiring a rule. Returns a pending proposal; the owner
226
681
  * decides through the normal approval surface.
@@ -260,6 +715,9 @@ export class AssistantEvolutionService extends Service {
260
715
  sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
261
716
  digest: candidate.evidenceDigest,
262
717
  total: candidate.evidenceTotal,
718
+ window: this.config.evaluationWindow,
719
+ scopeWatermark: candidate.scopeWatermark,
720
+ taskRevisions: candidate.taskRevisions,
263
721
  },
264
722
  };
265
723
  }
@@ -284,7 +742,11 @@ export class AssistantEvolutionService extends Service {
284
742
  }
285
743
  mutation = {
286
744
  op: 'retire',
745
+ scopeKey,
287
746
  ruleId: existing.id,
747
+ situation: existing.situation,
748
+ guidance: existing.guidance,
749
+ generation: existing.generation,
288
750
  expectedVersion: existing.version,
289
751
  reason: input.mutation.reason,
290
752
  evaluation: candidate.stats,
@@ -293,6 +755,9 @@ export class AssistantEvolutionService extends Service {
293
755
  sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
294
756
  digest: candidate.evidenceDigest,
295
757
  total: candidate.evidenceTotal,
758
+ window: this.config.evaluationWindow,
759
+ scopeWatermark: candidate.scopeWatermark,
760
+ taskRevisions: candidate.taskRevisions,
296
761
  },
297
762
  };
298
763
  }
@@ -313,14 +778,14 @@ export class AssistantEvolutionService extends Service {
313
778
  ttlMs,
314
779
  ...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
315
780
  };
316
- const local = this.store.createProposal({
781
+ const local = this.withLearningWriterFence(mutation, () => this.store.createProposal({
317
782
  idempotencyKey,
318
783
  requester,
319
784
  principal: route.principal,
320
785
  mutation,
321
786
  expiresAt: Date.now() + ttlMs,
322
787
  creationIntent,
323
- });
788
+ }));
324
789
  if (local.policyProposalId !== undefined) {
325
790
  if (local.status !== 'pending') {
326
791
  return result(local, true, local.resultRuleId === undefined ? undefined : this.store.getRule(local.resultRuleId));
@@ -338,7 +803,17 @@ export class AssistantEvolutionService extends Service {
338
803
  reconcileProposals(limit) {
339
804
  this.assertActive();
340
805
  const settled = [];
341
- for (const pending of this.store.listPendingProposals(limit ?? this.config.reconcileLimit)) {
806
+ const bounded = limit ?? this.config.reconcileLimit;
807
+ const queue = new Map();
808
+ for (const proposal of this.store.listUnattachedProposalIntents(bounded)) {
809
+ queue.set(proposal.proposalId, proposal);
810
+ }
811
+ for (const proposal of this.store.listPendingProposals(bounded)) {
812
+ if (queue.size >= bounded)
813
+ break;
814
+ queue.set(proposal.proposalId, proposal);
815
+ }
816
+ for (const pending of queue.values()) {
342
817
  try {
343
818
  const result = pending.policyProposalId === undefined
344
819
  ? this.submitLocalProposal(pending, false)
@@ -355,11 +830,72 @@ export class AssistantEvolutionService extends Service {
355
830
  this.store.deferPendingProposal(pending.proposalId);
356
831
  }
357
832
  }
833
+ this.reconcileApplicationPresentations(limit);
834
+ this.lastReconciledAt = this.now();
358
835
  return settled;
359
836
  }
837
+ /**
838
+ * Publish domain terminal receipts independently from proposal polling. A
839
+ * crash after Delivery accepts but before the local ack simply republishes the
840
+ * same revision and digest on restart.
841
+ */
842
+ reconcileApplicationPresentations(limit) {
843
+ this.assertActive();
844
+ const sink = this.presentationSink;
845
+ if (sink === undefined)
846
+ return 0;
847
+ const bounded = limit ?? this.config.reconcileLimit;
848
+ let published = 0;
849
+ for (const entry of this.store.listPendingEvolutionApplicationReceipts(bounded)) {
850
+ if (this.presentationSink?.token !== sink.token)
851
+ return published;
852
+ const receipt = entry.receipt;
853
+ try {
854
+ sink.registration.publish(this.applicationPresentation(receipt));
855
+ this.store.settleEvolutionApplicationPublication({
856
+ localProposalId: receipt.localProposalId,
857
+ receiptDigest: receipt.receiptDigest,
858
+ outcome: 'published',
859
+ });
860
+ published += 1;
861
+ }
862
+ catch (error) {
863
+ const code = error instanceof Error
864
+ ? `delivery-${error.name.normalize('NFC').toLowerCase().replaceAll(/[^a-z0-9]+/gu, '-').slice(0, 80)}`
865
+ : 'delivery-unknown-error';
866
+ this.store.settleEvolutionApplicationPublication({
867
+ localProposalId: receipt.localProposalId,
868
+ receiptDigest: receipt.receiptDigest,
869
+ outcome: 'retry',
870
+ error: code,
871
+ });
872
+ }
873
+ }
874
+ return published;
875
+ }
876
+ applicationPresentation(receipt) {
877
+ return Object.freeze({
878
+ presentationKey: `approval-application:${receipt.policyProposalId}`,
879
+ originalOutboxIdempotencyKey: `approval-card:${receipt.policyProposalId}`,
880
+ revision: receipt.revision,
881
+ presentation: Object.freeze({
882
+ kind: 'approval-application',
883
+ policyProposalId: receipt.policyProposalId,
884
+ localProposalId: receipt.localProposalId,
885
+ applicationStatus: receipt.applicationStatus,
886
+ operation: receipt.operation,
887
+ terminalAt: receipt.terminalAt,
888
+ receiptDigest: receipt.receiptDigest,
889
+ ...(receipt.ruleId === undefined ? {} : { ruleId: receipt.ruleId }),
890
+ ...(receipt.resultingRuleVersion === undefined
891
+ ? {} : { resultingRuleVersion: receipt.resultingRuleVersion }),
892
+ ...(receipt.ruleStatus === undefined ? {} : { ruleStatus: receipt.ruleStatus }),
893
+ }),
894
+ });
895
+ }
360
896
  submitLocalProposal(local, replayed) {
361
897
  const intent = local.creationIntent;
362
- if (intent === undefined) {
898
+ if (intent === undefined || (intent.dispatch !== undefined && intent.dispatch.routeVersion !== 2)) {
363
899
  const conflict = this.store.settleProposal({
364
900
  proposalId: local.proposalId,
365
901
  securityConflict: true,
@@ -375,7 +911,7 @@ export class AssistantEvolutionService extends Service {
375
911
  diff: intent.diff,
376
912
  summary: intent.summary,
377
913
  notAfter: local.expiresAt,
378
- ...(intent.dispatch === undefined ? {} : { dispatch: intent.dispatch }),
914
+ ...(intent.dispatch?.routeVersion === 2 ? { dispatch: intent.dispatch } : {}),
379
915
  };
380
916
  const recovered = this.policy.recoverOrCreateProposal(recoveryInput);
381
917
  if (recovered.kind === 'abandoned') {
@@ -383,7 +919,8 @@ export class AssistantEvolutionService extends Service {
383
919
  proposalId: local.proposalId,
384
920
  securityConflict: true,
385
921
  });
386
- return result(conflict.proposal, replayed || recovered.replayed || conflict.replayed, conflict.rule);
922
+ const completed = this.store.completeAbandonedProposalRecovery(local.proposalId);
923
+ return result(completed, replayed || recovered.replayed || conflict.replayed, conflict.rule);
387
924
  }
388
925
  const decision = recovered.proposal;
389
926
  const snapshot = this.policy.getProposal(decision.proposalId);
@@ -441,12 +978,27 @@ export class AssistantEvolutionService extends Service {
441
978
  return undefined;
442
979
  try {
443
980
  const terminal = validateApprovalSettlement(snapshot, local.settlementExpectation);
444
- const applied = this.store.settleProposal({
445
- proposalId: local.proposalId,
446
- policyStatus: terminal.status,
447
- policyVersion: terminal.version,
448
- reviewExpectation: local.settlementExpectation,
449
- });
981
+ let applied;
982
+ try {
983
+ applied = this.withLearningWriterFence(local.mutation, () => this.store.settleProposal({
984
+ proposalId: local.proposalId,
985
+ policyStatus: terminal.status,
986
+ policyVersion: terminal.version,
987
+ reviewExpectation: local.settlementExpectation,
988
+ }));
989
+ }
990
+ catch (error) {
991
+ if (!(error instanceof AssistantEvolutionError) || error.code !== 'forbidden')
992
+ throw error;
993
+ // A stale/missing authoritative fence is itself the domain terminal.
994
+ // This conflict-only transaction cannot apply a rule and therefore does
995
+ // not need to acquire Evaluation in the reverse lock order.
996
+ applied = this.store.settleProposal({
997
+ proposalId: local.proposalId,
998
+ securityConflict: true,
999
+ });
1000
+ }
1001
+ this.reconcileApplicationPresentations();
450
1002
  return result(applied.proposal, replayed || applied.replayed, applied.rule);
451
1003
  }
452
1004
  catch (error) {
@@ -456,6 +1008,7 @@ export class AssistantEvolutionService extends Service {
456
1008
  proposalId: local.proposalId,
457
1009
  securityConflict: true,
458
1010
  });
1011
+ this.reconcileApplicationPresentations();
459
1012
  return result(conflict.proposal, replayed || conflict.replayed, conflict.rule);
460
1013
  }
461
1014
  }
@@ -463,7 +1016,8 @@ export class AssistantEvolutionService extends Service {
463
1016
  if (this.delivery !== undefined) {
464
1017
  const dispatch = this.delivery.prepareAgentApproval(agent, { sourceId: evolutionApprovalSource });
465
1018
  const workspace = agent?.session.header.cwd;
466
- if (dispatch.sourceId !== evolutionApprovalSource || dispatch.workspace !== workspace
1019
+ if (dispatch.routeVersion !== 2
1020
+ || dispatch.sourceId !== evolutionApprovalSource || dispatch.workspace !== workspace
467
1021
  || dispatch.principal.trim() === '') {
468
1022
  throw new AssistantEvolutionError('invalid-input', 'authenticated approval route does not match the Agent');
469
1023
  }
@@ -478,6 +1032,315 @@ export class AssistantEvolutionService extends Service {
478
1032
  }
479
1033
  return { principal };
480
1034
  }
1035
+ /**
1036
+ * Acquire Evaluation first and Evolution only inside its synchronous callback.
1037
+ * No code holding an Evolution writer lock is allowed to call this method.
1038
+ */
1039
+ withLearningWriterFence(mutation, callback) {
1040
+ if (mutation.op === 'owner-undo')
1041
+ return callback();
1042
+ const evidence = mutation.evidence;
1043
+ if (evidence === undefined || evidence.scopeWatermark === undefined
1044
+ || evidence.taskRevisions === undefined || evidence.taskRevisions.length !== evidence.total
1045
+ || evidence.taskRevisions.length < 1) {
1046
+ throw new AssistantEvolutionError('forbidden', 'evidence-dependent Evolution operations require a complete Evaluation writer fence');
1047
+ }
1048
+ const scopeKey = mutation.op === 'adopt' ? mutation.input.scopeKey : mutation.scopeKey;
1049
+ let scope;
1050
+ try {
1051
+ const parsed = JSON.parse(scopeKey);
1052
+ if (!Array.isArray(parsed) || parsed.length !== 2
1053
+ || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
1054
+ throw new Error('invalid');
1055
+ scope = { workspace: parsed[0], preset: parsed[1] };
1056
+ }
1057
+ catch {
1058
+ throw new AssistantEvolutionError('forbidden', 'Evolution evidence scope is not canonical');
1059
+ }
1060
+ const evaluation = this.context.get('assistantEvaluation');
1061
+ if (evaluation === undefined
1062
+ || typeof evaluation.withTrustedLearningWriterFence !== 'function') {
1063
+ throw new AssistantEvolutionError('forbidden', 'authoritative Evaluation writer fence capability is unavailable');
1064
+ }
1065
+ const result = evaluation.withTrustedLearningWriterFence({
1066
+ scope: canonicalEvaluationHostScope(scope),
1067
+ scopeWatermark: evidence.scopeWatermark,
1068
+ evidence: evidence.taskRevisions,
1069
+ }, callback);
1070
+ if (typeof result !== 'object' || result === null || !Object.isFrozen(result)
1071
+ || result.matched !== true) {
1072
+ const reason = typeof result === 'object' && result !== null && 'reason' in result
1073
+ ? String(result.reason)
1074
+ : 'invalid-fence-receipt';
1075
+ throw new AssistantEvolutionError('forbidden', `Evolution evidence is stale at the authoritative writer fence: ${reason}`);
1076
+ }
1077
+ return result.value;
1078
+ }
1079
+ candidatesForScope(scopeKey) {
1080
+ return this.store.candidates({
1081
+ scopeKey,
1082
+ window: this.config.evaluationWindow,
1083
+ minSample: this.config.minSample,
1084
+ adoptFailureRate: this.config.adoptFailureRate,
1085
+ retireFailureRate: this.config.retireFailureRate,
1086
+ limit: this.config.maxCandidates,
1087
+ evidenceSampleLimit: this.config.maxEvidenceSamples,
1088
+ });
1089
+ }
1090
+ trustedTaskLearningReceipt(scope, evaluationId) {
1091
+ const evaluation = this.context.get('assistantEvaluation');
1092
+ if (evaluation === undefined || typeof evaluation.getTrustedTaskLearningProjection !== 'function') {
1093
+ throw new AssistantEvolutionError('forbidden', 'authoritative assistantEvaluation lookup is unavailable');
1094
+ }
1095
+ const receipt = evaluation.getTrustedTaskLearningProjection({
1096
+ scope: canonicalEvaluationHostScope(scope),
1097
+ outcomeId: evaluationId,
1098
+ });
1099
+ if (receipt === undefined) {
1100
+ throw new AssistantEvolutionError('not-found', 'trusted Evaluation outcome was not found in the exact scope');
1101
+ }
1102
+ const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
1103
+ if (!Object.isFrozen(receipt) || !Object.isFrozen(receipt.scope)
1104
+ || !Object.isFrozen(receipt.projection)
1105
+ || receipt.triggerOutcomeId !== evaluationId || receipt.scopeKey !== scopeKey
1106
+ || receipt.scope.workspace !== scope.workspace
1107
+ || receipt.scope.preset !== scope.preset
1108
+ || !Number.isSafeInteger(receipt.scopeWatermark) || receipt.scopeWatermark < 1
1109
+ || (receipt.projection.subjectKind !== 'automation-run'
1110
+ && receipt.projection.subjectKind !== 'outcome')
1111
+ || hostText(receipt.projection.subjectRef, 'projection.subjectRef', 1_000)
1112
+ !== receipt.projection.subjectRef
1113
+ || !Number.isSafeInteger(receipt.projection.version) || receipt.projection.version < 1
1114
+ || receipt.projection.version > 1_000_000_000
1115
+ || !/^[a-f\d]{64}$/u.test(receipt.projection.digest)
1116
+ || (receipt.projection.disposition !== 'upsert'
1117
+ && receipt.projection.disposition !== 'retract')
1118
+ || evaluationLearningProjectionDigest(receipt) !== receipt.projection.digest) {
1119
+ throw new AssistantEvolutionError('forbidden', 'Evaluation returned a mismatched trusted receipt');
1120
+ }
1121
+ if (receipt.execution !== undefined && (!Object.isFrozen(receipt.execution)
1122
+ || !Object.isFrozen(receipt.execution.source)
1123
+ || !Object.isFrozen(receipt.execution.evaluator)
1124
+ || !Object.isFrozen(receipt.execution.evidence)
1125
+ || receipt.execution.evidence.some(entry => !Object.isFrozen(entry)))) {
1126
+ throw new AssistantEvolutionError('forbidden', 'Evaluation execution component is mutable');
1127
+ }
1128
+ if (receipt.objective !== undefined && (!Object.isFrozen(receipt.objective)
1129
+ || !Object.isFrozen(receipt.objective.source)
1130
+ || !Object.isFrozen(receipt.objective.evaluator)
1131
+ || !Object.isFrozen(receipt.objective.evidence)
1132
+ || receipt.objective.evidence.some(entry => !Object.isFrozen(entry)))) {
1133
+ throw new AssistantEvolutionError('forbidden', 'Evaluation objective component is mutable');
1134
+ }
1135
+ if (receipt.projection.disposition === 'upsert'
1136
+ && (receipt.objective === undefined
1137
+ || (receipt.objective.status !== 'achieved' && receipt.objective.status !== 'not-achieved')
1138
+ || receipt.projection.evidenceOutcomeId !== receipt.objective.outcomeId)) {
1139
+ throw new AssistantEvolutionError('forbidden', 'an upsert task revision requires one exact binary objective component');
1140
+ }
1141
+ return receipt;
1142
+ }
1143
+ applyTrustedTaskLearningReceipt(scope, receipt) {
1144
+ const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
1145
+ if (receipt.projection.disposition === 'retract') {
1146
+ return this.store.applyTaskLearningProjection({
1147
+ scopeKey,
1148
+ scopeWatermark: receipt.scopeWatermark,
1149
+ subjectKind: receipt.projection.subjectKind,
1150
+ subjectRef: receipt.projection.subjectRef,
1151
+ version: receipt.projection.version,
1152
+ digest: receipt.projection.digest,
1153
+ disposition: 'retract',
1154
+ situation: receipt.situation,
1155
+ occurredAt: receipt.execution?.occurredAt ?? receipt.objective?.occurredAt ?? this.now(),
1156
+ });
1157
+ }
1158
+ const objective = receipt.objective;
1159
+ const verified = this.verifiedAutomationAttribution(receipt, scopeKey);
1160
+ if (verified?.executionKind === 'host') {
1161
+ throw new AssistantEvolutionError('forbidden', 'Host runbook outcomes are not behavioural learning subjects');
1162
+ }
1163
+ return this.store.applyTaskLearningProjection({
1164
+ scopeKey,
1165
+ scopeWatermark: receipt.scopeWatermark,
1166
+ subjectKind: receipt.projection.subjectKind,
1167
+ subjectRef: receipt.projection.subjectRef,
1168
+ version: receipt.projection.version,
1169
+ digest: receipt.projection.digest,
1170
+ disposition: 'upsert',
1171
+ situation: receipt.situation,
1172
+ outcome: objective.status === 'achieved' ? 'succeeded' : 'failed',
1173
+ detail: `authoritative Evaluation objective: ${objective.status}`,
1174
+ evidenceRef: objective.outcomeId,
1175
+ occurredAt: receipt.execution?.occurredAt ?? objective.occurredAt,
1176
+ ...(verified?.attribution === undefined ? {} : verified.attribution),
1177
+ });
1178
+ }
1179
+ verifiedAutomationAttribution(receipt, scopeKey) {
1180
+ if (receipt.projection.subjectKind !== 'automation-run') {
1181
+ return undefined;
1182
+ }
1183
+ const execution = receipt.execution;
1184
+ const runReferences = execution?.evidence.filter(entry => entry.kind === 'automation-run') ?? [];
1185
+ if (execution === undefined || runReferences.length !== 1
1186
+ || runReferences[0].ref !== receipt.projection.subjectRef
1187
+ || !receipt.situation.startsWith('automation:')
1188
+ || execution.source.kind !== 'automation'
1189
+ || execution.source.id !== 'assistant-automations'
1190
+ || execution.evaluator.id !== 'assistant-automations'
1191
+ || !/^(?:terminal|host-runbook)-v[1-9][0-9]*$/u.test(execution.evaluator.version)) {
1192
+ throw new AssistantEvolutionError('forbidden', 'Evaluation receipt has no exact authoritative Automation execution component');
1193
+ }
1194
+ const automationId = receipt.situation.slice('automation:'.length);
1195
+ if (automationId === '') {
1196
+ throw new AssistantEvolutionError('forbidden', 'Evaluation receipt has an invalid automation situation');
1197
+ }
1198
+ const reference = runReferences[0];
1199
+ const resolver = this.context.get('assistantAutomations');
1200
+ if (resolver === undefined || typeof resolver.resolveQualityEvidence !== 'function'
1201
+ || typeof resolver.validateQualityEvidence !== 'function') {
1202
+ throw new AssistantEvolutionError('forbidden', 'authoritative assistantAutomations production proof is unavailable');
1203
+ }
1204
+ let proof;
1205
+ let valid = false;
1206
+ try {
1207
+ proof = resolver.resolveQualityEvidence({
1208
+ automationId,
1209
+ runId: reference.ref,
1210
+ expectedScope: { workspace: receipt.scope.workspace, preset: receipt.scope.preset },
1211
+ expectedSituation: receipt.situation,
1212
+ expectedOccurredAt: execution.occurredAt,
1213
+ evidenceRef: { kind: 'automation-run', ref: reference.ref },
1214
+ });
1215
+ valid = proof !== undefined && resolver.validateQualityEvidence(proof);
1216
+ }
1217
+ catch {
1218
+ throw new AssistantEvolutionError('forbidden', 'assistantAutomations rejected the production quality proof');
1219
+ }
1220
+ if (!valid || proof === undefined || !Object.isFrozen(proof)
1221
+ || (proof.executionKind !== 'agent' && proof.executionKind !== 'host')
1222
+ || proof.automationId !== automationId || proof.runId !== reference.ref
1223
+ || proof.scope.workspace !== receipt.scope.workspace
1224
+ || proof.scope.preset !== receipt.scope.preset
1225
+ || proof.situation !== receipt.situation || proof.occurredAt !== execution.occurredAt
1226
+ || (proof.status === 'timed_out' ? 'timed-out' : proof.status) !== execution.status
1227
+ || proof.evidenceRef.kind !== 'automation-run' || proof.evidenceRef.ref !== reference.ref) {
1228
+ throw new AssistantEvolutionError('forbidden', 'assistantAutomations returned a mismatched production quality proof');
1229
+ }
1230
+ const hasRule = proof.ruleId !== undefined;
1231
+ const hasGuidanceVersion = proof.guidanceVersion !== undefined;
1232
+ // A normal Agent automation always has a session id. Before the first rule
1233
+ // exists there is intentionally no guidance attribution; that baseline is
1234
+ // what makes bootstrap learning possible.
1235
+ if (proof.executionKind === 'host') {
1236
+ if (proof.sessionId !== undefined || hasRule || hasGuidanceVersion) {
1237
+ throw new AssistantEvolutionError('forbidden', 'Host automation proof contains Agent attribution');
1238
+ }
1239
+ return Object.freeze({
1240
+ executionKind: 'host',
1241
+ learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
1242
+ });
1243
+ }
1244
+ if (!hasRule && !hasGuidanceVersion) {
1245
+ return Object.freeze({
1246
+ executionKind: 'agent',
1247
+ learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
1248
+ });
1249
+ }
1250
+ if (proof.sessionId === undefined || !hasRule || !hasGuidanceVersion) {
1251
+ throw new AssistantEvolutionError('forbidden', 'automation guidance attribution proof is incomplete');
1252
+ }
1253
+ const exposure = this.store.captureGuidanceExposure(proof.sessionId, scopeKey, receipt.situation);
1254
+ if (exposure === undefined || exposure.ruleId !== proof.ruleId
1255
+ || exposure.guidanceVersion !== proof.guidanceVersion
1256
+ || execution.occurredAt < exposure.exposedAt) {
1257
+ throw new AssistantEvolutionError('forbidden', 'automation guidance attribution does not match an exact Evolution exposure');
1258
+ }
1259
+ return Object.freeze({
1260
+ executionKind: 'agent',
1261
+ learningSubjectRef: JSON.stringify(['automation-run', proof.runId]),
1262
+ attribution: Object.freeze({ ruleId: exposure.ruleId, guidanceVersion: exposure.guidanceVersion }),
1263
+ });
1264
+ }
1265
+ normalizedRollbackInput(input) {
1266
+ this.assertActive();
1267
+ if (!this.config.autonomousRollback) {
1268
+ throw new AssistantEvolutionError('forbidden', 'autonomous evolution rollback is disabled');
1269
+ }
1270
+ const ruleId = hostText(input.ruleId, 'ruleId', 200);
1271
+ if (!/^rule-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
1272
+ .test(ruleId)) {
1273
+ throw new AssistantEvolutionError('invalid-input', 'ruleId must be an immutable server-issued rule ID');
1274
+ }
1275
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1
1276
+ || input.expectedVersion > 1_000_000_000) {
1277
+ throw new AssistantEvolutionError('invalid-input', 'expectedVersion must be a positive safe integer');
1278
+ }
1279
+ return { ruleId, expectedVersion: input.expectedVersion };
1280
+ }
1281
+ rollbackForScope(scopeKey, input) {
1282
+ return this.store.rollbackRule({
1283
+ scopeKey,
1284
+ ruleId: input.ruleId,
1285
+ expectedVersion: input.expectedVersion,
1286
+ window: this.config.evaluationWindow,
1287
+ minSample: this.config.minSample,
1288
+ retireFailureRate: this.config.retireFailureRate,
1289
+ evidenceSampleLimit: this.config.maxEvidenceSamples,
1290
+ });
1291
+ }
1292
+ authorizeHost(input, action, resourceId) {
1293
+ this.assertActive();
1294
+ const scope = exactEvolutionHostScope(input.scope);
1295
+ const principal = hostText(input.principal, 'principal', 500);
1296
+ const operationId = hostText(input.operationId, 'operationId', 500);
1297
+ const canonicalResourceId = hostText(resourceId, 'resourceId', 500);
1298
+ const scopeKey = canonicalEvolutionScope(scope.workspace, scope.preset);
1299
+ const idempotencyDigest = createHash('sha256').update(JSON.stringify([
1300
+ HOST_RECOVERY_BACKGROUND_ID,
1301
+ scopeKey,
1302
+ principal,
1303
+ action,
1304
+ canonicalResourceId,
1305
+ operationId,
1306
+ ])).digest('hex');
1307
+ const decision = this.policy.authorize({
1308
+ subject: {
1309
+ kind: 'background',
1310
+ id: HOST_RECOVERY_BACKGROUND_ID,
1311
+ workspace: scope.workspace,
1312
+ principal,
1313
+ },
1314
+ action,
1315
+ resource: { kind: 'evolution', id: canonicalResourceId },
1316
+ context: { initiator: 'background' },
1317
+ }, { idempotencyKey: `evolution-host:${idempotencyDigest}` });
1318
+ if (decision.effect !== 'allow') {
1319
+ throw new AssistantEvolutionError('forbidden', `policy denied Host evolution ${action}`);
1320
+ }
1321
+ return scopeKey;
1322
+ }
1323
+ authorizeSupervisedGrowthAnalyst(agent, action, resourceId) {
1324
+ const scopeKey = this.authorize(agent, action, resourceId);
1325
+ const execution = agent.ctx
1326
+ .get('assistantAutomationExecution');
1327
+ if (typeof execution !== 'object' || execution === null || Array.isArray(execution)
1328
+ || !Object.isFrozen(execution)
1329
+ || Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId') {
1330
+ throw new AssistantEvolutionError('forbidden', 'adoption analyst requires the immutable Automation execution context');
1331
+ }
1332
+ const installed = execution;
1333
+ if (installed.mode !== 'production'
1334
+ || installed.automationId !== SUPERVISED_GROWTH_ANALYST_AUTOMATION_ID
1335
+ || typeof installed.occurrenceId !== 'string') {
1336
+ throw new AssistantEvolutionError('forbidden', 'adoption analyst is restricted to its exact production Automation');
1337
+ }
1338
+ const occurrenceId = hostText(installed.occurrenceId, 'occurrenceId', 500);
1339
+ if (occurrenceId !== installed.occurrenceId) {
1340
+ throw new AssistantEvolutionError('forbidden', 'analyst occurrence identity is not canonical');
1341
+ }
1342
+ return Object.freeze({ scopeKey, occurrenceId });
1343
+ }
481
1344
  /** Guidance block for the current active rules, as injected into a session. */
482
1345
  guidance(agent) {
483
1346
  this.assertActive();
@@ -493,8 +1356,32 @@ export class AssistantEvolutionService extends Service {
493
1356
  });
494
1357
  }
495
1358
  injectGuidance(agent) {
496
- if (!this.active || this.injected.has(agent))
1359
+ if (!this.active)
497
1360
  return;
1361
+ const execution = agent.ctx
1362
+ .get('assistantAutomationExecution');
1363
+ // Missing execution context is a normal foreground session. If a Host marks
1364
+ // the Agent as automation-scoped, only production may receive guidance or
1365
+ // write an exposure receipt. Malformed future modes fail closed as preview.
1366
+ let exactAutomationSituation;
1367
+ if (execution !== undefined) {
1368
+ if (typeof execution !== 'object' || execution === null || Array.isArray(execution)
1369
+ || Object.keys(execution).sort().join(',') !== 'automationId,mode,occurrenceId')
1370
+ return;
1371
+ const installed = execution;
1372
+ if (installed.mode !== 'production' || typeof installed.automationId !== 'string'
1373
+ || typeof installed.occurrenceId !== 'string')
1374
+ return;
1375
+ let automationId;
1376
+ try {
1377
+ automationId = hostText(installed.automationId, 'automationId', 500);
1378
+ hostText(installed.occurrenceId, 'occurrenceId', 500);
1379
+ }
1380
+ catch {
1381
+ return;
1382
+ }
1383
+ exactAutomationSituation = `automation:${automationId}`;
1384
+ }
498
1385
  const workspace = agent.session.header.cwd;
499
1386
  const preset = agent.session.header.agentPreset;
500
1387
  if (workspace === undefined || !isAbsolute(workspace) || preset === undefined || preset.trim() === '')
@@ -506,15 +1393,34 @@ export class AssistantEvolutionService extends Service {
506
1393
  return;
507
1394
  const scopeKey = canonicalEvolutionScope(workspace, preset);
508
1395
  const sessionId = String(agent.session.id);
1396
+ const exposedCount = this.store.countGuidanceExposures(sessionId, scopeKey);
1397
+ const remaining = Math.max(0, this.config.maxInjectedRules - exposedCount);
1398
+ // Before Automation installs its execution context the session is
1399
+ // indistinguishable from foreground. With a multi-rule budget, reserve one
1400
+ // slot so the exact post-setup rule can still fit inside the global cap.
1401
+ const reserved = execution === undefined && this.config.maxInjectedRules > 1 ? 1 : 0;
1402
+ const injectionLimit = Math.max(0, remaining - reserved);
1403
+ if (injectionLimit === 0)
1404
+ return;
509
1405
  const unseen = this.store.listRules(scopeKey, 'active').filter(rule => !this.store.hasGuidanceExposure(sessionId, scopeKey, rule.id, rule.generation));
510
- const snapshot = buildGuidanceSnapshot(unseen, {
1406
+ // A production Automation's exact rule must be selected before the global
1407
+ // max-rules slice. This is also safe when the post-setup hook follows an
1408
+ // earlier session-start: durable exposures remove already injected rules,
1409
+ // so the hook can still inject the previously omitted exact rule.
1410
+ const prioritized = exactAutomationSituation === undefined
1411
+ ? unseen
1412
+ : [
1413
+ ...unseen.filter(rule => rule.situation === exactAutomationSituation),
1414
+ ...unseen.filter(rule => rule.situation !== exactAutomationSituation),
1415
+ ];
1416
+ const snapshot = buildGuidanceSnapshot(prioritized, {
511
1417
  maxBytes: this.config.maxGuidanceBytes,
512
- maxRules: this.config.maxInjectedRules,
1418
+ maxRules: injectionLimit,
1419
+ ...(exactAutomationSituation === undefined
1420
+ ? {} : { prioritySituation: exactAutomationSituation }),
513
1421
  });
514
- if (snapshot.text === '') {
515
- this.injected.add(agent);
1422
+ if (snapshot.text === '')
516
1423
  return;
517
- }
518
1424
  agent.inject(createUserMessage({
519
1425
  content: [{ type: 'text', text: snapshot.text }],
520
1426
  source: { kind: 'plugin', plugin: 'assistant-evolution' },
@@ -528,7 +1434,6 @@ export class AssistantEvolutionService extends Service {
528
1434
  guidanceVersion: rule.generation,
529
1435
  });
530
1436
  }
531
- this.injected.add(agent);
532
1437
  }
533
1438
  authorize(agent, action, resourceId) {
534
1439
  this.assertActive();