From 7a147b655dd3a2481700b8fa2e84571ec252798d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 8 Jun 2026 22:54:26 +0100 Subject: [PATCH] Add Discord timeout actuator for urgent moderation --- .env.example | 2 + notes/fresh-workspace-handoff.md | 1 + package.json | 1 + scripts/moderate-discord-user.mjs | 266 ++++++++++++++++++++++ scripts/run-void-moderator-rumination.ps1 | 149 +++++++++++- state/evidence.jsonl | 1 + state/map.yaml | 1 + 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 scripts/moderate-discord-user.mjs diff --git a/.env.example b/.env.example index bf79752..a97d101 100644 --- a/.env.example +++ b/.env.example @@ -109,3 +109,5 @@ VOIDBOT_HEALTHCHECK_TASK_NAME=VoidBot Operations Watchdog VOIDBOT_HEALTHCHECK_INTERVAL_MINUTES=60 VOIDBOT_MODERATION_TASK_NAME=Void Moderator Rumination VOIDBOT_MODERATION_INTERVAL_MINUTES=15 +VOID_MODERATION_ENFORCEMENT_MODE=log_only +VOID_MODERATION_TIMEOUT_MINUTES=60 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 88d2d08..ee204da 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -52,6 +52,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Agency has a typed pressure surface now: `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests. Candidate interventions are possible speech/actions emerging from pressure, not the pressure itself. `npm run moderation:agency-pressure:fixture` verifies the pressure enters `.cc`, renders in self-state summary, and affects mood speaking pressure. - Strong advocacy pressure now has to account for its own mouth. The rumination runner derives `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, and only a live queued candidate counts as release for that obligation. A pass may queue/defer a candidate or cool/retire the pressure, but silent `[]` fails visibly and an older spoken candidate does not buy permanent absolution. `npm run moderation:rumination:speech-pressure-fixture` covers the silent, spoken-history, and queued-candidate paths. - Severe safety evidence now has the same no-silent-advance wall. The rumination runner projects `urgentModerationWitnesses` from recent Discord history for credible violence, weapon, robbery, stalking, or sexual-boundary evidence, and the parent rejects silent `[]` unless an existing open case or new moderation operation accounts for the witness. `npm run moderation:rumination:urgent-safety-fixture` uses the archived 2026-06-08 Lobo threat sequence to prove the guard. +- Urgent safety handling can now enforce through a parent-owned Discord action path. `VOID_MODERATION_ENFORCEMENT_MODE=log_only` is the non-destructive default, `notify_owner` sends an owner DM, and `timeout` calls `scripts/moderate-discord-user.mjs` to apply a Discord member timeout for the first non-owner urgent witness author after the model/open-case operation passes validation. The child prompt still does not own raw sanction authority. - Deferred candidates are not delivered speech and no longer satisfy active advocacy pressure by themselves. The runner now delivers existing queued candidates with delivery targets, not only candidates proposed by the current model pass. The first live repair used Metacrat's explicit channel permission to move the stranded AquariumSynthHost candidate from deferred/no-target to queued/channel-target, then delivered it through the normal bot transport and recorded a typed speech receipt. - Parent-owned delivery now also checks canonical typed speech receipts before using the mouth. If a queued candidate targets a Discord reply that already has a receipt, the runner retires it instead of answering the same message twice in slightly different prose. Typed state writes also normalize historical receipts to keep the earliest canonical reply per `channelId + replyToMessageId`. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - Speaking pressure is allowed to refresh while moderation is active. `scripts/simulate-void-mood.mjs` still skips sleep-cycle mutation and memory maintenance under a recent moderation lock, but it now reloads typed state and applies `update_speaking_pressure` first, so fresh canonical speech receipts update `lastSpokeAt`, damping, and `needToSpeak` even during long rumination runs. diff --git a/package.json b/package.json index 78fb969..76e4cc5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "qdrant:up": "powershell -ExecutionPolicy Bypass -File scripts/start-qdrant.ps1", "rag:backfill": "tsx scripts/backfill-discord-logs.ts", "moderation:recent-history": "node scripts/export-recent-discord-history.mjs", + "moderation:discord-action": "node scripts/moderate-discord-user.mjs", "moderation:purge-context": "tsx scripts/purge-discord-context.ts", "agents:feed-consensus": "node scripts/feed-codex-chat-consensus.mjs", "moderation:random-history": "node scripts/export-random-discord-history.mjs", diff --git a/scripts/moderate-discord-user.mjs b/scripts/moderate-discord-user.mjs new file mode 100644 index 0000000..8de3934 --- /dev/null +++ b/scripts/moderate-discord-user.mjs @@ -0,0 +1,266 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { TextDecoder } from "node:util"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const env = readLocalEnv(); + const botToken = env.DISCORD_BOT_TOKEN?.trim(); + const guildId = options.guildId ?? env.DISCORD_GUILD_ID?.trim(); + + if (!guildId) { + throw new Error("Provide --guild-id or configure DISCORD_GUILD_ID."); + } + if (!options.userId) { + throw new Error("Provide --user-id."); + } + + const reason = options.reason ?? "VoidBot moderation action."; + if (options.dryRun) { + process.stdout.write(`${JSON.stringify({ + ok: true, + dryRun: true, + action: options.action, + guildId, + userId: options.userId, + durationMinutes: options.durationMinutes, + reason, + })}\n`); + return; + } + + if (!botToken) { + throw new Error("DISCORD_BOT_TOKEN is required."); + } + + const result = await executeModerationAction({ + botToken, + guildId, + userId: options.userId, + action: options.action, + durationMinutes: options.durationMinutes, + reason, + }); + + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +function parseArgs(args) { + const options = { + action: undefined, + guildId: undefined, + userId: undefined, + durationMinutes: 60, + reason: undefined, + dryRun: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + switch (argument) { + case "--action": + options.action = args[index + 1]; + index += 1; + break; + case "--guild-id": + options.guildId = args[index + 1]; + index += 1; + break; + case "--user-id": + options.userId = args[index + 1]; + index += 1; + break; + case "--duration-minutes": + options.durationMinutes = Number(args[index + 1]); + index += 1; + break; + case "--reason": + options.reason = args[index + 1]; + index += 1; + break; + case "--dry-run": + options.dryRun = true; + break; + default: + throw new Error(`Unknown argument: ${argument}`); + } + } + + if (!["timeout", "ban", "kick", "remove-timeout"].includes(options.action)) { + throw new Error("Provide --action timeout, ban, kick, or remove-timeout."); + } + if (!Number.isFinite(options.durationMinutes) || options.durationMinutes < 1) { + throw new Error("--duration-minutes must be a positive number."); + } + + return options; +} + +async function executeModerationAction(input) { + switch (input.action) { + case "timeout": + return timeoutMember(input); + case "remove-timeout": + return removeTimeout(input); + case "ban": + return banMember(input); + case "kick": + return kickMember(input); + default: + throw new Error(`Unsupported moderation action: ${input.action}`); + } +} + +async function timeoutMember(input) { + const until = new Date(Date.now() + input.durationMinutes * 60_000).toISOString(); + const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, { + method: "PATCH", + reason: input.reason, + body: { + communication_disabled_until: until, + }, + }); + return { + ok: true, + action: "timeout", + guildId: input.guildId, + userId: input.userId, + durationMinutes: input.durationMinutes, + communicationDisabledUntil: until, + status: response.status, + }; +} + +async function removeTimeout(input) { + const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, { + method: "PATCH", + reason: input.reason, + body: { + communication_disabled_until: null, + }, + }); + return { + ok: true, + action: "remove-timeout", + guildId: input.guildId, + userId: input.userId, + status: response.status, + }; +} + +async function banMember(input) { + const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/bans/${input.userId}`, { + method: "PUT", + reason: input.reason, + body: { + delete_message_seconds: 0, + }, + }); + return { + ok: true, + action: "ban", + guildId: input.guildId, + userId: input.userId, + status: response.status, + }; +} + +async function kickMember(input) { + const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, { + method: "DELETE", + reason: input.reason, + }); + return { + ok: true, + action: "kick", + guildId: input.guildId, + userId: input.userId, + status: response.status, + }; +} + +async function discordFetch(botToken, path, options) { + const headers = { + Authorization: `Bot ${botToken}`, + "X-Audit-Log-Reason": encodeURIComponent(options.reason ?? "VoidBot moderation action."), + }; + const request = { + method: options.method, + headers, + }; + + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + request.body = JSON.stringify(options.body); + } + + const response = await fetch(`https://discord.com/api/v10${path}`, request); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord moderation action failed: ${response.status} ${text}`); + } + return response; +} + +function readLocalEnv() { + const envPath = resolve(repoRoot, ".env"); + const parsed = { ...process.env }; + + try { + const raw = stripLeadingBom(readTextFileFlexible(envPath)); + Object.assign(parsed, parseDotEnv(raw)); + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + + return parsed; +} + +function parseDotEnv(raw) { + const result = {}; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex === -1) { + continue; + } + const key = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; +} + +function readTextFileFlexible(path) { + const buffer = readFileSync(path); + if (buffer.length >= 2) { + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return new TextDecoder("utf-16le").decode(buffer); + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return new TextDecoder("utf-16be").decode(buffer); + } + } + return new TextDecoder("utf-8").decode(buffer); +} + +function stripLeadingBom(value) { + return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; +} + +await main(); diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 028756b..3cf6b5c 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -43,6 +43,7 @@ $contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination- $recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs" $repoActivityScriptPath = Join-Path $repoRoot "scripts\export-recent-repo-activity.mjs" $selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs" +$moderationActionScriptPath = Join-Path $repoRoot "scripts\moderate-discord-user.mjs" $sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT) } else { @@ -455,6 +456,135 @@ function Assert-UrgentModerationWitnessesHandled { throw "Urgent moderation witness '$((Get-ObjectPropertyString -Value $firstWitness -Name "messageId"))' requires an open case or moderation candidate; silent cursor advancement is forbidden." } +function Get-PrimaryUrgentModerationWitness { + param([object[]] $Witnesses) + + $ownerId = Get-ConfigValue -Name "DISCORD_OWNER_ID" -Values $envValues + return @( + $Witnesses | + Where-Object { + $authorId = Get-ObjectPropertyString -Value $_ -Name "authorId" + -not [string]::IsNullOrWhiteSpace($authorId) -and $authorId -ne $ownerId + } + ) | Select-Object -First 1 +} + +function Format-UrgentModerationSummary { + param( + [Parameter(Mandatory = $true)] + $Witness, + [string] $Mode + ) + + $authorName = Get-ObjectPropertyString -Value $Witness -Name "authorName" + $authorId = Get-ObjectPropertyString -Value $Witness -Name "authorId" + $messageId = Get-ObjectPropertyString -Value $Witness -Name "messageId" + $channelId = Get-ObjectPropertyString -Value $Witness -Name "channelId" + $content = Get-ObjectPropertyString -Value $Witness -Name "content" + $categories = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $Witness -Name "categories")) -join ", " + + return @( + "VoidBot urgent moderation witness" + "mode: $Mode" + "author: $authorName ($authorId)" + "channel: $channelId" + "message: $messageId" + "categories: $categories" + "excerpt: $content" + ) -join [Environment]::NewLine +} + +function Invoke-OwnerModerationNotification { + param( + [Parameter(Mandatory = $true)] + [string] $Content + ) + + $contentPath = Join-Path $statusDir ("moderation-urgent-notify-{0}.txt" -f ([Guid]::NewGuid().ToString("n"))) + [System.IO.File]::WriteAllText($contentPath, $Content, [System.Text.UTF8Encoding]::new($false)) + $output = & node $sendMessageScriptPath --owner-dm --content-file $contentPath 2>&1 + $exitCode = $LASTEXITCODE + Remove-Item -LiteralPath $contentPath -Force -ErrorAction SilentlyContinue + if ($exitCode -ne 0) { + throw "Owner urgent moderation notification failed: $($output | Out-String)" + } + return $output | ConvertFrom-Json +} + +function Invoke-UrgentModerationEnforcement { + param( + [object[]] $Witnesses, + [string] $Mode, + [int] $TimeoutMinutes + ) + + if ($Witnesses.Count -eq 0) { + return $null + } + + $normalizedMode = if ([string]::IsNullOrWhiteSpace($Mode)) { "log_only" } else { $Mode.Trim().ToLowerInvariant() } + if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only")) { + return @{ + mode = $normalizedMode + status = "skipped" + reason = "enforcement_mode_non_destructive" + witnessCount = [int]$Witnesses.Count + } + } + + $witness = Get-PrimaryUrgentModerationWitness -Witnesses $Witnesses + if ($null -eq $witness) { + return @{ + mode = $normalizedMode + status = "skipped" + reason = "no_non_owner_witness_author" + witnessCount = [int]$Witnesses.Count + } + } + + $summary = Format-UrgentModerationSummary -Witness $witness -Mode $normalizedMode + + if ($normalizedMode -eq "notify_owner") { + $notification = Invoke-OwnerModerationNotification -Content $summary + return @{ + mode = $normalizedMode + status = "notified_owner" + witnessMessageId = Get-ObjectPropertyString -Value $witness -Name "messageId" + authorId = Get-ObjectPropertyString -Value $witness -Name "authorId" + notification = $notification + } + } + + if ($normalizedMode -eq "timeout") { + $authorId = Get-ObjectPropertyString -Value $witness -Name "authorId" + $messageId = Get-ObjectPropertyString -Value $witness -Name "messageId" + $reason = "VoidBot urgent safety witness $messageId" + $actionArgs = @( + $moderationActionScriptPath, + "--action", "timeout", + "--user-id", $authorId, + "--duration-minutes", ([string]$TimeoutMinutes), + "--reason", $reason + ) + $actionOutput = & node @actionArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Discord timeout action failed: $($actionOutput | Out-String)" + } + $notification = Invoke-OwnerModerationNotification -Content ($summary + [Environment]::NewLine + "action: timeout $TimeoutMinutes minutes") + return @{ + mode = $normalizedMode + status = "timed_out" + witnessMessageId = $messageId + authorId = $authorId + timeoutMinutes = [int]$TimeoutMinutes + action = $actionOutput | ConvertFrom-Json + notification = $notification + } + } + + throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, or timeout." +} + function Get-TypedSelfState { $script = @" const core = require('./packages/core/dist'); @@ -1076,7 +1206,7 @@ if (Test-Path $operationOutputPath) { Remove-Item -LiteralPath $operationOutputPath -Force } -foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $sendMessageScriptPath)) { +foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $sendMessageScriptPath)) { if (-not (Test-Path $requiredPath)) { throw "Missing required helper at $requiredPath" } @@ -1124,6 +1254,14 @@ $codexTimeoutSeconds = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATIO $publicRoomChannelId = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_CHANNEL_ID" -Values $envValues $publicRoomPersonaName = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_PERSONA_NAME" -Values $envValues $publicRoomPersonaAvatarUrl = Get-ConfigValue -Name "VOID_PUBLIC_ROOM_PERSONA_AVATAR_URL" -Values $envValues +$moderationEnforcementMode = Get-ConfigValue -Name "VOID_MODERATION_ENFORCEMENT_MODE" -Values $envValues +if ([string]::IsNullOrWhiteSpace($moderationEnforcementMode)) { + $moderationEnforcementMode = "log_only" +} +$moderationTimeoutMinutes = Get-ConfigValue -Name "VOID_MODERATION_TIMEOUT_MINUTES" -Values $envValues +if ([string]::IsNullOrWhiteSpace($moderationTimeoutMinutes)) { + $moderationTimeoutMinutes = "60" +} $typedContext = Get-TypedSelfState $typedState = $typedContext.typedState @@ -1305,6 +1443,7 @@ if (-not (Test-Path $operationOutputPath)) { Append-RunLog "reading proposed operation output." $proposedOperations = @(Convert-ToOperationArray -Value (Read-JsonFile -Path $operationOutputPath)) $appliedOperations = @() +$moderationEnforcementResult = $null Assert-UrgentModerationWitnessesHandled -Witnesses $urgentModerationWitnesses -Operations $proposedOperations -TypedState $typedState Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations @@ -1319,6 +1458,13 @@ foreach ($operation in $proposedOperations) { $appliedOperations += Apply-TypedOperation -Operation $operation } +if (-not $NoPost) { + $moderationEnforcementResult = Invoke-UrgentModerationEnforcement ` + -Witnesses $urgentModerationWitnesses ` + -Mode $moderationEnforcementMode ` + -TimeoutMinutes ([int]$moderationTimeoutMinutes) +} + $observedCursorMessageId = Get-ObjectPropertyString -Value $observedCursor -Name "lastReviewedMessageId" $observedCursorTimestamp = Get-ObjectPropertyString -Value $observedCursor -Name "lastReviewedTimestamp" if ( @@ -1405,6 +1551,7 @@ $finalStatus = [ordered]@{ proposedOperationCount = [int]@($proposedOperations).Count appliedOperationCount = [int]@($appliedOperations).Count repoCursorOperationCount = [int]@($repoCursorOperations).Count + moderationEnforcement = $moderationEnforcementResult deliveredCandidateCount = [int]$deliveredCandidateCount stateUpdated = [bool](@($appliedOperations).Count -gt 0) tracePath = [string]$tracePath diff --git a/state/evidence.jsonl b/state/evidence.jsonl index dbc2e14..4e26fb9 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -332,3 +332,4 @@ {"ts":"2026-06-08T18:16:43Z","type":"repair","status":"ok","note":"Repo Face, mood, and moderation state reads failed because live .cc state contained legacy moderation_cursor.userStatuses plus old void.persona_affect/stressResponses shapes. Added narrow decode migrations, promoted legacy affect to void.face_affect, and repaired .voidbot/private/void-self-state.cc to current eight document types."} {"ts":"2026-06-08T18:16:45Z","type":"repair","status":"ok","note":"VoidBot swarm CultMesh publication failed after state repair because the derived voidbot-swarm-state.cc had duplicate global documents and the renderer used obsolete CultLib subpath imports. The renderer now loads exported CultLib packages, archives duplicate-global derived stores, and regenerates a clean provider store; Odin latest-surface includes voidbot.swarm."} {"ts":"2026-06-08T21:17:23Z","type":"repair","status":"ok","note":"Void failed to moderate the 2026-06-08 Lobo threat sequence because severe safety evidence could be cursor-advanced by the model path without an open case. Added parent-side urgentModerationWitnesses, forbade silent [] over urgent witnesses, added a real fixture over the archived incident, and seeded live open case 1513515503329480734."} +{"ts":"2026-06-08T21:51:40Z","type":"implementation","status":"ok","note":"Added parent-owned Discord moderation enforcement for urgent safety witnesses: runner records/validates the urgent case first, then can log only, notify owner, or apply a Discord timeout via scripts/moderate-discord-user.mjs according to VOID_MODERATION_ENFORCEMENT_MODE."} diff --git a/state/map.yaml b/state/map.yaml index f3fd4ef..04bf26a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -182,6 +182,7 @@ accepted_design: - Values and personality activations should not stop at tone and salience. The moderation runtime now needs explicit `discomforts`, `active_tensions`, `self_advocacy_requests`, and `world_advocacy_requests` so sustained value-backed pressure can turn into opinions, interventions, and requests for change about both Void's own wiring and the wider repo/room/lore world. - Strong active advocacy pressure is no longer allowed to vanish into quiet `[]`. Once self/world advocacy reaches meaningful intensity, the runner should force a live queued candidate or an explicit cool/retire decision; stale spoken history is not enough to count as a release valve for a still-active pressure. - Severe safety evidence is no longer allowed to vanish into cursor advancement. The moderation runner projects parent-detected `urgentModerationWitnesses` for credible violence, weapon, robbery, stalking, or sexual-boundary evidence, and rejects silent `[]` unless an existing open case or a new moderation operation accounts for the witness. + - Urgent safety handling now has a parent-owned Discord action path. The child model may account for the witness by opening moderation state, but sanctions stay behind `VOID_MODERATION_ENFORCEMENT_MODE`: `log_only` records the case without acting, `notify_owner` sends an owner DM, and `timeout` calls `scripts/moderate-discord-user.mjs` to apply a Discord member timeout before cursor advancement. This is the narrow moderation actuator; do not give the child prompt raw ban/kick/timeout authority. - When the thought surface changes shape, legacy memories may need narrow migrations, but the old broad cleanup organ is gone. New memory writes should enter through typed meaning-preserving operations so routine repair bureaucracy is unnecessary. - Over-chewed private seams should eventually crystallize into identity instead of living forever as “active thoughts.” Once a thought has clearly stopped being live curiosity and become self-doctrine, retire it from incubation, store it as one `identity_seam`, and let the identity/value surface carry it from then on. - Identity crystallization should usually have a mouth. When a seam becomes part of Void's doctrine, queue a warmer candidate intervention and let the room hear it unless novelty or timing gives a real reason to hold back.