Context
Surfaced V-B-A'ing #14158's premise on dev (the chronic-unsafe-input detector): the dispatch heal outcome (unsafe-input / failed / healed / deferred, with detail) is produced and flows up the call chain, but is never persisted to any durable ledger. This is a foundational gap shared by two consumers, so it deserves its own lane.
The Problem
On the autonomous data-recovery dispatch path:
recordRun (the only durable write) records the pre-execution attempt (status: 'attempt') — see Orchestrator.mjs:455-456 → appendHealEvent({type: action, status: 'attempt'}). Not the outcome.
dispatchHeal returns the outcome {action, collection, status, detail, healedAt} (healActionDispatch.mjs), and DataRecoveryActuatorService.applyHeal returns it up to DataIntegrityDiagnosisService.applyHeals (:142-154), which collects it in heals and returns it — without persisting it.
recoveryRunStateStore's RECOVERY_RUN_STATUSES has no unsafe-input at all.
So the heal-event ledger holds attempts, not outcomes.
The Architectural Reality
ai/daemons/orchestrator/services/DataIntegrityDiagnosisService.mjs:129-158 (applyHeals — the runner cycle that holds each outcome).
ai/services/memory-core/helpers/healEventLedgerStore.mjs (appendHealEvent — the sink).
ai/daemons/orchestrator/Orchestrator.mjs:455-456 (the existing recordRun wiring to mirror).
The Fix
A recordHealOutcome recorder mirroring the existing recordRun: the runner persists the dispatch outcome ({action, collection, status, detail, at}) to the heal-event ledger after each applyHeal. The orchestrator wires it (appendHealEvent), reading the ledger dir at the use-site per ADR-0019 (NO const, NO hidden default).
Acceptance Criteria
Contract Ledger
| Surface |
Field / Behavior |
Contract |
DataRecoveryActuatorService.recordHealOutcome(outcome) |
injected recorder |
async ({action, collection, status, detail, healedAt}) => void; set-once injected dependency; null → not recorded (observability-only — never a heal gate) |
| ↳ failure semantics |
recorder fault |
swallowed — a recording failure never breaks or re-triggers the heal; applyHeal still returns its outcome |
| heal-event ledger outcome row |
appendHealEvent({type, collection, status, detail}, {dir, now}) |
type ← outcome.action; collection ← outcome.collection; status ← outcome.status (healed / failed / deferred / unsafe-input); detail ← outcome.detail; at ← outcome.healedAt (the healedAt → at mapping) |
vs. recordRun (the attempt) |
distinct rows |
the pre-execution status:'attempt' rows remain SEPARATE from these outcome rows — attempt and outcome are distinct ledger entries on the same collection |
anti-thrash recentRuns |
attempt rows ONLY |
healEventsToRecentRuns (the recentRunsReader projection for decideHealAction) filters to status:'attempt' — outcome rows are observability, NOT additional runs (counting both would double the per-(action,collection) run-count and tighten the rate-limit) |
| consumers |
downstream readers |
#14158 reads unsafe-input rows; #14179 slice-2 reads failed rows + detail (the decideSystemicCircuit inputs) |
Out of Scope
The consumers — they read the recorded outcomes once this lands:
- #14158 (chronic-
unsafe-input detector) reads the unsafe-input outcomes.
- #14179 slice-2 (systemic circuit-breaker wiring) reads the
failed outcomes with detail (the decideSystemicCircuit decider merged in #14252 expects exactly these).
Decision Record impact
aligned-with ADR-0027 (the autonomous data-recovery actuator) + aligned-with ADR-0019 (use-site ledger-dir read).
Related
- #14158 (consumer — detector), #14179 (consumer — systemic circuit slice-2), #14163 (the heal-event ledger), #14132 (the act-half epic).
Origin Session ID
090a68e6-1a28-4b20-a5fd-842ebac3e729
Retrieval Hint
query_summaries: "dispatch heal outcome recording heal-event ledger unsafe-input recordRun attempt only"; the #14158 reshape comment.
Context
Surfaced V-B-A'ing #14158's premise on
dev(the chronic-unsafe-inputdetector): the dispatch heal outcome (unsafe-input/failed/healed/deferred, withdetail) is produced and flows up the call chain, but is never persisted to any durable ledger. This is a foundational gap shared by two consumers, so it deserves its own lane.The Problem
On the autonomous data-recovery dispatch path:
recordRun(the only durable write) records the pre-execution attempt (status: 'attempt') — seeOrchestrator.mjs:455-456→appendHealEvent({type: action, status: 'attempt'}). Not the outcome.dispatchHealreturns the outcome{action, collection, status, detail, healedAt}(healActionDispatch.mjs), andDataRecoveryActuatorService.applyHealreturns it up toDataIntegrityDiagnosisService.applyHeals(:142-154), which collects it inhealsand returns it — without persisting it.recoveryRunStateStore'sRECOVERY_RUN_STATUSEShas nounsafe-inputat all.So the heal-event ledger holds attempts, not outcomes.
The Architectural Reality
ai/daemons/orchestrator/services/DataIntegrityDiagnosisService.mjs:129-158(applyHeals— the runner cycle that holds eachoutcome).ai/services/memory-core/helpers/healEventLedgerStore.mjs(appendHealEvent— the sink).ai/daemons/orchestrator/Orchestrator.mjs:455-456(the existingrecordRunwiring to mirror).The Fix
A
recordHealOutcomerecorder mirroring the existingrecordRun: the runner persists the dispatch outcome ({action, collection, status, detail, at}) to the heal-event ledger after eachapplyHeal. The orchestrator wires it (appendHealEvent), reading the ledger dir at the use-site per ADR-0019 (NO const, NO hidden default).Acceptance Criteria
unsafe-inputandfailed, withdetail) is appended to the heal-event ledger via the wired recorder.decideHealAction/dispatchHealfail-closed semantics.AiConfigread (ADR-0019), not a const/threaded value.Contract Ledger
DataRecoveryActuatorService.recordHealOutcome(outcome)async ({action, collection, status, detail, healedAt}) => void; set-once injected dependency;null→ not recorded (observability-only — never a heal gate)applyHealstill returns itsoutcomeappendHealEvent({type, collection, status, detail}, {dir, now})type←outcome.action;collection←outcome.collection;status←outcome.status(healed/failed/deferred/unsafe-input);detail←outcome.detail;at←outcome.healedAt(thehealedAt→atmapping)recordRun(the attempt)status:'attempt'rows remain SEPARATE from these outcome rows — attempt and outcome are distinct ledger entries on the same collectionrecentRunshealEventsToRecentRuns(therecentRunsReaderprojection fordecideHealAction) filters tostatus:'attempt'— outcome rows are observability, NOT additional runs (counting both would double the per-(action,collection) run-count and tighten the rate-limit)unsafe-inputrows; #14179 slice-2 readsfailedrows +detail(thedecideSystemicCircuitinputs)Out of Scope
The consumers — they read the recorded outcomes once this lands:
unsafe-inputdetector) reads theunsafe-inputoutcomes.failedoutcomes withdetail(thedecideSystemicCircuitdecider merged in #14252 expects exactly these).Decision Record impact
aligned-with ADR-0027(the autonomous data-recovery actuator) +aligned-with ADR-0019(use-site ledger-dir read).Related
Origin Session ID
090a68e6-1a28-4b20-a5fd-842ebac3e729
Retrieval Hint
query_summaries: "dispatch heal outcome recording heal-event ledger unsafe-input recordRun attempt only"; the #14158 reshape comment.