diff --git a/package.json b/package.json index e197d91..2fa0a9f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "moderation:memory-maintenance": "powershell -ExecutionPolicy Bypass -File scripts/run-void-memory-maintenance.ps1", "moderation:memory-maintenance:sleep-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-memory-maintenance-sleep-fixture.ps1", "moderation:memory-lifecycle:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-memory-lifecycle-fixture.ps1", + "moderation:state-boundary-normalization:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-state-boundary-normalization-fixture.ps1", "moderation:short-term-clustering:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-short-term-clustering-fixture.ps1", "moderation:agency-pressure:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-agency-pressure-fixture.ps1", "moderation:rumination:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-fixture.ps1", diff --git a/packages/core/src/void-self-state-service.ts b/packages/core/src/void-self-state-service.ts index acfea35..a695e63 100644 --- a/packages/core/src/void-self-state-service.ts +++ b/packages/core/src/void-self-state-service.ts @@ -33,7 +33,7 @@ export interface VoidSelfStateServiceOptions { } export interface VoidSelfStateOperationResult { - operation: VoidSelfStateOperation["operation"] | "ensure_identity_profile"; + operation: VoidSelfStateOperation["operation"] | "ensure_identity_profile" | "repair_typed_state"; canonicalPath: string; typedDocumentsWritten: number; } @@ -85,6 +85,23 @@ export async function ensureVoidSelfStateIdentityProfile( }; } +export async function repairVoidSelfStateTypedDocuments( + options: VoidSelfStateServiceOptions, +): Promise { + const canonicalPath = resolve(options.canonicalPath); + const cache = createVoidSelfStateCache(canonicalPath); + await cache.pullAllBackingStores(); + + const typedState = readTypedStateOrEmpty(cache, options.identity); + await writeTypedState(cache, typedState); + + return { + operation: "repair_typed_state", + canonicalPath, + typedDocumentsWritten: 7, + }; +} + function createVoidSelfStateCache(canonicalPath: string): CultCache { return CultCache.builder() .withRegistry(voidSelfStateDocumentRegistry) @@ -118,6 +135,7 @@ async function writeTypedState( cache: CultCache, state: VoidSelfStateTypedProjection, ): Promise { + normalizeTypedStateForWrite(state); normalizeDeliveryReceipts(state); await cache.putGlobal(voidSelfProfileDocument, stripUndefined(state.selfProfile)); await cache.putGlobal(voidModerationCursorDocument, stripUndefined(state.moderationCursor)); @@ -180,10 +198,75 @@ function normalizeDeliveryReceipts(state: VoidSelfStateTypedProjection): void { .slice(-24); } +function normalizeTypedStateForWrite( + state: VoidSelfStateTypedProjection, + observedAt = new Date().toISOString(), +): void { + let thoughtMemoryChanged = false; + for (const memory of state.thoughtMemory.shortTerm) { + thoughtMemoryChanged = clampFutureTimestampFields(memory, observedAt, [ + "createdAt", + "updatedAt", + "retiredAt", + ]) || thoughtMemoryChanged; + } + if (isFutureTimestamp(state.thoughtMemory.updatedAt, observedAt)) { + state.thoughtMemory.updatedAt = observedAt; + thoughtMemoryChanged = true; + } + if (thoughtMemoryChanged && isFutureTimestamp(state.thoughtMemory.updatedAt, observedAt)) { + state.thoughtMemory.updatedAt = observedAt; + } + + let candidateInterventionsChanged = false; + for (const intervention of state.candidateInterventions.interventions) { + candidateInterventionsChanged = normalizeQueuedCandidateIntervention(intervention, observedAt) + || candidateInterventionsChanged; + candidateInterventionsChanged = clampFutureTimestampFields(intervention, observedAt, [ + "createdAt", + "updatedAt", + "spokenAt", + "retiredAt", + ]) || candidateInterventionsChanged; + } + if (isFutureTimestamp(state.candidateInterventions.updatedAt, observedAt)) { + state.candidateInterventions.updatedAt = observedAt; + candidateInterventionsChanged = true; + } + if (candidateInterventionsChanged) { + state.candidateInterventions.updatedAt = observedAt; + } +} + +function clampFutureTimestampFields>( + value: T, + observedAt: string, + fields: string[], +): boolean { + let changed = false; + for (const field of fields) { + const timestamp = value[field]; + if (typeof timestamp === "string" && isFutureTimestamp(timestamp, observedAt)) { + value[field as keyof T] = observedAt as T[keyof T]; + changed = true; + } + } + return changed; +} + +function isFutureTimestamp(timestamp: string, observedAt: string): boolean { + const timestampMs = Date.parse(timestamp); + const observedAtMs = Date.parse(observedAt); + return Number.isFinite(timestampMs) && + Number.isFinite(observedAtMs) && + timestampMs > observedAtMs; +} + function applyTypedOperation( state: VoidSelfStateTypedProjection, operation: VoidSelfStateOperation, ): void { + const appliedAt = new Date().toISOString(); switch (operation.operation) { case "record_reviewed_messages": state.moderationCursor.lastReviewedMessageId = operation.lastReviewedMessageId; @@ -214,11 +297,14 @@ function applyTypedOperation( closeCasesForReceipt(state.moderationCursor, operation.receipt); return; case "record_short_term_memory": - recordShortTermMemory(state.thoughtMemory, operation.memory); + recordShortTermMemory( + state.thoughtMemory, + normalizeShortTermMemoryOperation(state.thoughtMemory, operation.memory, appliedAt), + ); state.thoughtMemory.shortTerm = state.thoughtMemory.shortTerm .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) .slice(-48); - state.thoughtMemory.updatedAt = operation.memory.updatedAt; + state.thoughtMemory.updatedAt = appliedAt; return; case "prune_short_term_memories": state.thoughtMemory.shortTerm = state.thoughtMemory.shortTerm.filter( @@ -255,8 +341,12 @@ function applyTypedOperation( state.thoughtMemory.updatedAt = operation.thread.updatedAt; return; case "queue_candidate_intervention": - upsertBy(state.candidateInterventions.interventions, operation.intervention, (entry) => entry.interventionId); - state.candidateInterventions.updatedAt = operation.intervention.updatedAt; + upsertBy( + state.candidateInterventions.interventions, + normalizeCandidateInterventionOperation(state.candidateInterventions, operation.intervention, appliedAt), + (entry) => entry.interventionId, + ); + state.candidateInterventions.updatedAt = appliedAt; return; case "retire_candidate_intervention": retireCandidateIntervention(state.candidateInterventions, operation); @@ -295,6 +385,50 @@ function applyTypedOperation( } } +function normalizeShortTermMemoryOperation( + thoughtMemory: VoidThoughtMemory, + memory: VoidThoughtMemory["shortTerm"][number], + appliedAt: string, +): VoidThoughtMemory["shortTerm"][number] { + const existing = thoughtMemory.shortTerm.find((entry) => entry.memoryId === memory.memoryId); + return { + ...memory, + createdAt: existing?.createdAt ?? appliedAt, + updatedAt: appliedAt, + }; +} + +function normalizeCandidateInterventionOperation( + candidates: VoidCandidateInterventions, + intervention: VoidCandidateInterventions["interventions"][number], + appliedAt: string, +): VoidCandidateInterventions["interventions"][number] { + const existing = candidates.interventions.find((entry) => entry.interventionId === intervention.interventionId); + const normalized = { + ...intervention, + createdAt: existing?.createdAt ?? appliedAt, + updatedAt: appliedAt, + }; + normalizeQueuedCandidateIntervention(normalized, appliedAt); + return normalized; +} + +function normalizeQueuedCandidateIntervention( + intervention: VoidCandidateInterventions["interventions"][number], + observedAt: string, +): boolean { + if (intervention.status !== "queued" || intervention.deliveryTarget) { + return false; + } + intervention.status = "deferred"; + intervention.updatedAt = observedAt; + intervention.tags = Array.from(new Set([ + ...intervention.tags, + "normalized:targetless-queued-to-deferred", + ])); + return true; +} + function recordShortTermMemory( thoughtMemory: VoidThoughtMemory, memory: VoidThoughtMemory["shortTerm"][number], diff --git a/scripts/smoke-void-state-boundary-normalization-fixture.ps1 b/scripts/smoke-void-state-boundary-normalization-fixture.ps1 new file mode 100644 index 0000000..624e50e --- /dev/null +++ b/scripts/smoke-void-state-boundary-normalization-fixture.ps1 @@ -0,0 +1,125 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $repoRoot + +$tempRoot = Join-Path $env:TEMP ("void-state-boundary-normalization-fixture-" + [guid]::NewGuid().ToString("n")) +$stateFilePath = Join-Path $tempRoot "void-self-state.cc" + +New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + +function Invoke-NodeChecked { + param([Parameter(Mandatory = $true)][string[]] $Arguments) + + $output = & node @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Node command failed: node $($Arguments -join ' ')" + } + return $output +} + +function Write-OperationFile { + param( + [Parameter(Mandatory = $true)][hashtable] $Operation, + [Parameter(Mandatory = $true)][string] $Name + ) + + $path = Join-Path $tempRoot $Name + $Operation | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $path -Encoding UTF8 + return $path +} + +function Assert-NotFutureTimestamp { + param( + [Parameter(Mandatory = $true)][string] $Timestamp, + [Parameter(Mandatory = $true)][string] $Label + ) + + $parsed = [DateTimeOffset]::Parse($Timestamp) + $now = [DateTimeOffset]::UtcNow.AddSeconds(2) + if ($parsed -gt $now) { + throw "$Label is still in the future: $Timestamp" + } +} + +try { + $futureMemory = @{ + operation = "record_short_term_memory" + memory = @{ + memoryId = "fixture-future-short-term" + kind = "project_seam" + target = @{ kind = "repo"; id = "VoidBot"; label = "VoidBot" } + summary = "Model-authored timestamps must not become canonical state time." + claim = "The parent state service owns canonical timestamps for model-originated writes." + tension = "If child timestamps are trusted, projection order can imply events that have not happened." + actionImplication = "Stamp short-term memory writes at the typed state boundary." + anchorRefs = @( + @{ + ref = "fixture:future-memory" + kind = "fixture" + summary = "Future timestamp normalization fixture." + } + ) + evidenceRefs = @() + createdAt = "2999-01-01T00:00:00.000Z" + updatedAt = "2999-01-01T00:00:00.000Z" + tags = @("repo:VoidBot", "topic:state-boundary") + } + } + + $targetlessCandidate = @{ + operation = "queue_candidate_intervention" + intervention = @{ + interventionId = "fixture-targetless-queued" + kind = "self_advocacy" + status = "queued" + target = @{ kind = "system"; id = "typed-state-boundary"; label = "Typed state boundary" } + summary = "Queued candidate without a delivery target should not project as deliverable." + draft = "A queued candidate needs an actual delivery target, or it is theater with a clipboard." + priority = 0.5 + mustEventuallyShare = $false + createdAt = "2999-01-01T00:00:00.000Z" + updatedAt = "2999-01-01T00:00:00.000Z" + tags = @("topic:state-boundary") + } + } + + foreach ($entry in @( + @{ Operation = $futureMemory; Name = "future-memory.json" }, + @{ Operation = $targetlessCandidate; Name = "targetless-candidate.json" } + )) { + $path = Write-OperationFile -Operation $entry.Operation -Name $entry.Name + Invoke-NodeChecked -Arguments @(".\scripts\void-self-state.mjs", "apply-operation", "--canonical", $stateFilePath, "--operation-file", $path) | Out-Null + } + + $stateJson = Invoke-NodeChecked -Arguments @("-e", "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })", $stateFilePath) + $state = $stateJson | ConvertFrom-Json + $memory = @($state.thoughtMemory.shortTerm | Where-Object { $_.memoryId -eq "fixture-future-short-term" })[0] + $candidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-targetless-queued" })[0] + + if ($memory.createdAt -eq "2999-01-01T00:00:00.000Z" -or $memory.updatedAt -eq "2999-01-01T00:00:00.000Z") { + throw "Short-term memory kept model-authored future timestamps." + } + Assert-NotFutureTimestamp -Timestamp $memory.createdAt -Label "short-term createdAt" + Assert-NotFutureTimestamp -Timestamp $memory.updatedAt -Label "short-term updatedAt" + + if ($candidate.status -ne "deferred") { + throw "Targetless queued candidate was not deferred; status is $($candidate.status)." + } + if (-not @($candidate.tags).Contains("normalized:targetless-queued-to-deferred")) { + throw "Targetless queued candidate was deferred without a normalization tag." + } + Assert-NotFutureTimestamp -Timestamp $candidate.createdAt -Label "candidate createdAt" + Assert-NotFutureTimestamp -Timestamp $candidate.updatedAt -Label "candidate updatedAt" + + @{ + status = "ok" + shortTermMemoryId = $memory.memoryId + candidateStatus = $candidate.status + candidateTags = @($candidate.tags) + } | ConvertTo-Json -Compress +} finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index def2321..67ae2fd 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -151,3 +151,4 @@ {"ts":"2026-05-18T16:04:09Z","type":"implementation","status":"ok","note":"Repo Face heartbeat failure traced to overlong identity publicDescription entering typed Face self-profile; capped identity defaults at the typed state boundary so rich registry doctrine can feed prompts without violating self-profile schema. Verified npm run build, manual heartbeat tick exit 0, scheduled task LastTaskResult 0, and Nibu heartbeat job 7232b2e6 completed."} {"ts":"2026-05-18T16:14:03Z","type":"implementation","status":"ok","note":"Epiphany/Face jurisdiction is now a first-class registry layer above repo identities. `epiphany-identities.ts` accepts explicit Epiphany records with Faces, grants, and jurisdictions, derives legacy repo identity compatibility when only old rows exist, exposes Epiphany/Face rows through the MCP identity listing, and projects Face doctrine into direct mention and heartbeat prompts. This establishes Nibu-style agency as the model: a Face belongs to an Epiphany, holds scoped grants, and may steward repos, canon, discussion, publications, Discord presence, and Aquarium embodiment without being identical to one repo."} {"ts":"2026-05-18T16:32:37Z","type":"implementation","status":"ok","note":"Repo Face heartbeat introductions now use parent-owned delivery: heartbeat prompts emit VOIDBOT_REPO_IDENTITY_POST directives instead of calling approval-gated MCP posting, the worker delivers the registered repo identity persona and records a Face-local speech receipt, and Face summaries render recent receipts. Verified Nibu heartbeat job e03fcab5 posted in Aquarium as Nibu and recorded receipt repo-identity:nibu:1505970734395297802."} +{"ts":"2026-05-18T17:06:08Z","type":"implementation","status":"ok","note":"Typed self-state write normalization now parent-stamps model-originated short-term memory and candidate intervention writes, clamps existing future timestamps on write, and defers targetless queued candidates with a normalization tag. Live .voidbot/private/void-self-state.cc repair cleared the inspection findings for rumination-2026-05-18-localcastbridge-runtime-sync-observability-gate, rumination-2026-05-18-aquasynth-compiler-custody-public-claim, and rumination-2026-05-18-localcastbridge-audio-field-gate."}