@dsh-enhanced/assistant-evolution 0.1.7 → 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),
@@ -26,6 +27,17 @@ const configSchema = Schema.object({
26
27
  autonomousRollback: Schema.boolean().default(false),
27
28
  });
28
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
+ }
29
41
  export class AssistantEvolutionError extends Error {
30
42
  code;
31
43
  constructor(code, message) {
@@ -34,6 +46,51 @@ export class AssistantEvolutionError extends Error {
34
46
  this.name = 'AssistantEvolutionError';
35
47
  }
36
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
+ }
37
94
  export function canonicalEvolutionScope(workspace, preset) {
38
95
  const normalizedPreset = preset.normalize('NFC').trim();
39
96
  if (!isAbsolute(workspace) || normalizedPreset === '') {
@@ -76,13 +133,15 @@ function ruleIdFromStableMutation(stable) {
76
133
  */
77
134
  export class AssistantEvolutionService extends Service {
78
135
  static Config = configSchema;
79
- static inject = ['assistantPolicy'];
136
+ static inject = ['assistantPolicy', 'assistantEvaluation'];
80
137
  store;
81
138
  policy;
139
+ context;
82
140
  config;
83
- injected = new WeakSet();
84
141
  now;
85
142
  delivery;
143
+ presentationProducerGeneration = `assistant-evolution-presentation:${randomUUID()}`;
144
+ presentationSink;
86
145
  active = true;
87
146
  lastReconciledAt = 0;
88
147
  constructor(ctx, input, options = {}) {
@@ -96,6 +155,7 @@ export class AssistantEvolutionService extends Service {
96
155
  const policy = ctx.get('assistantPolicy');
97
156
  if (policy === undefined)
98
157
  throw new Error('assistant-evolution: assistantPolicy service is required');
158
+ this.context = ctx;
99
159
  this.policy = policy;
100
160
  this.now = options.now ?? Date.now;
101
161
  this.store = new EvolutionStore({
@@ -105,6 +165,7 @@ export class AssistantEvolutionService extends Service {
105
165
  ctx.inject(['assistantDelivery'], deliveryCtx => {
106
166
  const delivery = deliveryCtx.get('assistantDelivery');
107
167
  this.delivery = delivery;
168
+ this.reconcileApplicationPresentations();
108
169
  return () => {
109
170
  if (this.delivery === delivery)
110
171
  this.delivery = undefined;
@@ -124,6 +185,7 @@ export class AssistantEvolutionService extends Service {
124
185
  });
125
186
  ctx.effect(() => () => {
126
187
  this.active = false;
188
+ this.presentationSink?.dispose();
127
189
  this.store.close();
128
190
  }, 'assistant-evolution.database');
129
191
  if (this.config.reconcileIntervalMs > 0) {
@@ -149,7 +211,47 @@ export class AssistantEvolutionService extends Service {
149
211
  lastReconciledAt: this.lastReconciledAt,
150
212
  });
151
213
  }
152
- /** Record one observed outcome as evidence. */
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. */
153
255
  recordEpisode(agent, input) {
154
256
  const scopeKey = this.authorize(agent, 'append', `situation:${input.situation}`);
155
257
  return this.store.recordEpisode({
@@ -159,14 +261,17 @@ export class AssistantEvolutionService extends Service {
159
261
  detail: input.detail,
160
262
  source: 'foreground',
161
263
  trust: 'self-reported',
264
+ evidenceKind: 'operational',
162
265
  ...(input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
163
266
  occurredAt: input.occurredAt,
164
267
  idempotencyKey: input.idempotencyKey,
165
268
  });
166
269
  }
167
270
  /**
168
- * Record an outcome observed by trusted local infrastructure rather than a model
169
- * 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.
170
275
  *
171
276
  * There is no Agent to authorize here, so this is deliberately narrow: it can
172
277
  * only append evidence. It cannot adopt, retire, or read rules, so an automation
@@ -200,11 +305,96 @@ export class AssistantEvolutionService extends Service {
200
305
  scopeKey,
201
306
  source: 'automation',
202
307
  trust: trusted ? 'trusted' : 'legacy',
308
+ evidenceKind: trusted ? 'operational' : 'legacy-unknown',
203
309
  ...(attributed
204
310
  ? { ruleId: receipt.ruleId, guidanceVersion: receipt.guidanceVersion }
205
311
  : input.ruleId === undefined ? {} : { claimedRuleId: input.ruleId }),
206
312
  });
207
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
+ }
208
398
  /** Query an exact post-injection receipt for trusted automation attribution. */
209
399
  async captureAutomationExposure(input) {
210
400
  this.assertActive();
@@ -221,20 +411,151 @@ export class AssistantEvolutionService extends Service {
221
411
  /** Candidate rule changes implied by current evidence. Never auto-applied. */
222
412
  candidates(agent) {
223
413
  const scopeKey = this.authorize(agent, 'inspect', 'candidates');
224
- return this.store.candidates({
225
- scopeKey,
226
- window: this.config.evaluationWindow,
227
- minSample: this.config.minSample,
228
- adoptFailureRate: this.config.adoptFailureRate,
229
- retireFailureRate: this.config.retireFailureRate,
230
- limit: this.config.maxCandidates,
231
- 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
+ }),
456
+ });
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,
232
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);
233
549
  }
234
550
  listRules(agent, status) {
235
551
  const scopeKey = this.authorize(agent, 'inspect', 'rules');
236
552
  return this.store.listRules(scopeKey, status);
237
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
+ }
238
559
  /**
239
560
  * Remove one exact active guidance generation through the opt-in low-risk lane.
240
561
  *
@@ -243,11 +564,42 @@ export class AssistantEvolutionService extends Service {
243
564
  * delegates the evidence/risk/reason decision to one transactional Host path.
244
565
  */
245
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) {
246
592
  this.assertActive();
247
- if (!this.config.autonomousRollback) {
248
- throw new AssistantEvolutionError('forbidden', 'autonomous evolution rollback is disabled');
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');
249
600
  }
250
- const ruleId = input.ruleId.normalize('NFC').trim();
601
+ const scopeKey = this.authorize(agent, 'propose', 'proposals');
602
+ const ruleId = hostText(input.ruleId, 'ruleId', 200);
251
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
252
604
  .test(ruleId)) {
253
605
  throw new AssistantEvolutionError('invalid-input', 'ruleId must be an immutable server-issued rule ID');
@@ -256,16 +608,73 @@ export class AssistantEvolutionService extends Service {
256
608
  || input.expectedVersion > 1_000_000_000) {
257
609
  throw new AssistantEvolutionError('invalid-input', 'expectedVersion must be a positive safe integer');
258
610
  }
259
- const scopeKey = this.authorize(agent, 'rollback', `rule:${ruleId}`);
260
- return this.store.rollbackRule({
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',
261
624
  scopeKey,
262
625
  ruleId,
626
+ situation: targetRule.situation,
627
+ guidance: targetRule.guidance,
628
+ generation: targetRule.generation,
263
629
  expectedVersion: input.expectedVersion,
264
- window: this.config.evaluationWindow,
265
- minSample: this.config.minSample,
266
- retireFailureRate: this.config.retireFailureRate,
267
- evidenceSampleLimit: this.config.maxEvidenceSamples,
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,
268
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);
269
678
  }
270
679
  /**
271
680
  * Propose adopting or retiring a rule. Returns a pending proposal; the owner
@@ -306,6 +715,9 @@ export class AssistantEvolutionService extends Service {
306
715
  sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
307
716
  digest: candidate.evidenceDigest,
308
717
  total: candidate.evidenceTotal,
718
+ window: this.config.evaluationWindow,
719
+ scopeWatermark: candidate.scopeWatermark,
720
+ taskRevisions: candidate.taskRevisions,
309
721
  },
310
722
  };
311
723
  }
@@ -330,7 +742,11 @@ export class AssistantEvolutionService extends Service {
330
742
  }
331
743
  mutation = {
332
744
  op: 'retire',
745
+ scopeKey,
333
746
  ruleId: existing.id,
747
+ situation: existing.situation,
748
+ guidance: existing.guidance,
749
+ generation: existing.generation,
334
750
  expectedVersion: existing.version,
335
751
  reason: input.mutation.reason,
336
752
  evaluation: candidate.stats,
@@ -339,6 +755,9 @@ export class AssistantEvolutionService extends Service {
339
755
  sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
340
756
  digest: candidate.evidenceDigest,
341
757
  total: candidate.evidenceTotal,
758
+ window: this.config.evaluationWindow,
759
+ scopeWatermark: candidate.scopeWatermark,
760
+ taskRevisions: candidate.taskRevisions,
342
761
  },
343
762
  };
344
763
  }
@@ -359,14 +778,14 @@ export class AssistantEvolutionService extends Service {
359
778
  ttlMs,
360
779
  ...(route.dispatch === undefined ? {} : { dispatch: route.dispatch }),
361
780
  };
362
- const local = this.store.createProposal({
781
+ const local = this.withLearningWriterFence(mutation, () => this.store.createProposal({
363
782
  idempotencyKey,
364
783
  requester,
365
784
  principal: route.principal,
366
785
  mutation,
367
786
  expiresAt: Date.now() + ttlMs,
368
787
  creationIntent,
369
- });
788
+ }));
370
789
  if (local.policyProposalId !== undefined) {
371
790
  if (local.status !== 'pending') {
372
791
  return result(local, true, local.resultRuleId === undefined ? undefined : this.store.getRule(local.resultRuleId));
@@ -384,7 +803,17 @@ export class AssistantEvolutionService extends Service {
384
803
  reconcileProposals(limit) {
385
804
  this.assertActive();
386
805
  const settled = [];
387
- 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()) {
388
817
  try {
389
818
  const result = pending.policyProposalId === undefined
390
819
  ? this.submitLocalProposal(pending, false)
@@ -401,12 +830,72 @@ export class AssistantEvolutionService extends Service {
401
830
  this.store.deferPendingProposal(pending.proposalId);
402
831
  }
403
832
  }
833
+ this.reconcileApplicationPresentations(limit);
404
834
  this.lastReconciledAt = this.now();
405
835
  return settled;
406
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
+ }
407
896
  submitLocalProposal(local, replayed) {
408
897
  const intent = local.creationIntent;
409
- if (intent === undefined) {
898
+ if (intent === undefined || (intent.dispatch !== undefined && intent.dispatch.routeVersion !== 2)) {
410
899
  const conflict = this.store.settleProposal({
411
900
  proposalId: local.proposalId,
412
901
  securityConflict: true,
@@ -422,7 +911,7 @@ export class AssistantEvolutionService extends Service {
422
911
  diff: intent.diff,
423
912
  summary: intent.summary,
424
913
  notAfter: local.expiresAt,
425
- ...(intent.dispatch === undefined ? {} : { dispatch: intent.dispatch }),
914
+ ...(intent.dispatch?.routeVersion === 2 ? { dispatch: intent.dispatch } : {}),
426
915
  };
427
916
  const recovered = this.policy.recoverOrCreateProposal(recoveryInput);
428
917
  if (recovered.kind === 'abandoned') {
@@ -430,7 +919,8 @@ export class AssistantEvolutionService extends Service {
430
919
  proposalId: local.proposalId,
431
920
  securityConflict: true,
432
921
  });
433
- 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);
434
924
  }
435
925
  const decision = recovered.proposal;
436
926
  const snapshot = this.policy.getProposal(decision.proposalId);
@@ -488,12 +978,27 @@ export class AssistantEvolutionService extends Service {
488
978
  return undefined;
489
979
  try {
490
980
  const terminal = validateApprovalSettlement(snapshot, local.settlementExpectation);
491
- const applied = this.store.settleProposal({
492
- proposalId: local.proposalId,
493
- policyStatus: terminal.status,
494
- policyVersion: terminal.version,
495
- reviewExpectation: local.settlementExpectation,
496
- });
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();
497
1002
  return result(applied.proposal, replayed || applied.replayed, applied.rule);
498
1003
  }
499
1004
  catch (error) {
@@ -503,6 +1008,7 @@ export class AssistantEvolutionService extends Service {
503
1008
  proposalId: local.proposalId,
504
1009
  securityConflict: true,
505
1010
  });
1011
+ this.reconcileApplicationPresentations();
506
1012
  return result(conflict.proposal, replayed || conflict.replayed, conflict.rule);
507
1013
  }
508
1014
  }
@@ -510,7 +1016,8 @@ export class AssistantEvolutionService extends Service {
510
1016
  if (this.delivery !== undefined) {
511
1017
  const dispatch = this.delivery.prepareAgentApproval(agent, { sourceId: evolutionApprovalSource });
512
1018
  const workspace = agent?.session.header.cwd;
513
- if (dispatch.sourceId !== evolutionApprovalSource || dispatch.workspace !== workspace
1019
+ if (dispatch.routeVersion !== 2
1020
+ || dispatch.sourceId !== evolutionApprovalSource || dispatch.workspace !== workspace
514
1021
  || dispatch.principal.trim() === '') {
515
1022
  throw new AssistantEvolutionError('invalid-input', 'authenticated approval route does not match the Agent');
516
1023
  }
@@ -525,6 +1032,315 @@ export class AssistantEvolutionService extends Service {
525
1032
  }
526
1033
  return { principal };
527
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
+ }
528
1344
  /** Guidance block for the current active rules, as injected into a session. */
529
1345
  guidance(agent) {
530
1346
  this.assertActive();
@@ -540,8 +1356,32 @@ export class AssistantEvolutionService extends Service {
540
1356
  });
541
1357
  }
542
1358
  injectGuidance(agent) {
543
- if (!this.active || this.injected.has(agent))
1359
+ if (!this.active)
544
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
+ }
545
1385
  const workspace = agent.session.header.cwd;
546
1386
  const preset = agent.session.header.agentPreset;
547
1387
  if (workspace === undefined || !isAbsolute(workspace) || preset === undefined || preset.trim() === '')
@@ -553,15 +1393,34 @@ export class AssistantEvolutionService extends Service {
553
1393
  return;
554
1394
  const scopeKey = canonicalEvolutionScope(workspace, preset);
555
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;
556
1405
  const unseen = this.store.listRules(scopeKey, 'active').filter(rule => !this.store.hasGuidanceExposure(sessionId, scopeKey, rule.id, rule.generation));
557
- 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, {
558
1417
  maxBytes: this.config.maxGuidanceBytes,
559
- maxRules: this.config.maxInjectedRules,
1418
+ maxRules: injectionLimit,
1419
+ ...(exactAutomationSituation === undefined
1420
+ ? {} : { prioritySituation: exactAutomationSituation }),
560
1421
  });
561
- if (snapshot.text === '') {
562
- this.injected.add(agent);
1422
+ if (snapshot.text === '')
563
1423
  return;
564
- }
565
1424
  agent.inject(createUserMessage({
566
1425
  content: [{ type: 'text', text: snapshot.text }],
567
1426
  source: { kind: 'plugin', plugin: 'assistant-evolution' },
@@ -575,7 +1434,6 @@ export class AssistantEvolutionService extends Service {
575
1434
  guidanceVersion: rule.generation,
576
1435
  });
577
1436
  }
578
- this.injected.add(agent);
579
1437
  }
580
1438
  authorize(agent, action, resourceId) {
581
1439
  this.assertActive();