Normalize typed self-state writes

This commit is contained in:
Meta Vens 2026-05-18 18:07:42 +01:00
commit 18b32613fb
4 changed files with 266 additions and 5 deletions

View file

@ -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",

View file

@ -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<VoidSelfStateOperationResult> {
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<void> {
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<T extends Record<string, unknown>>(
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],

View file

@ -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
}

View file

@ -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."}