From 7e4564a34fc72188fd224633c1e2fd3013cd1a7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 17:58:35 +0100 Subject: [PATCH] Type the Persona chat artifact contract --- .../src/bin/epiphany-persona-discord.rs | 143 +++++++++++++----- ...onceptual-substitution-audit-2026-07-12.md | 33 ++++ notes/fresh-workspace-handoff.md | 10 ++ .../cultnet/epiphany.persona-chat.schema.json | 19 ++- schemas/cultnet/index.json | 6 +- state/ledgers.msgpack | Bin 485050 -> 485726 bytes state/map.yaml | 2 +- 7 files changed, 169 insertions(+), 44 deletions(-) diff --git a/epiphany-core/src/bin/epiphany-persona-discord.rs b/epiphany-core/src/bin/epiphany-persona-discord.rs index e42ccd93..5e7c25b0 100644 --- a/epiphany-core/src/bin/epiphany-persona-discord.rs +++ b/epiphany-core/src/bin/epiphany-persona-discord.rs @@ -85,7 +85,7 @@ fn main() -> Result<()> { &artifact_dir, &cultmesh_store, &runtime_id, - "draft", + PersonaChatStatus::Draft, "drafted without posting", )? } @@ -137,7 +137,7 @@ fn run_draft( artifact_dir: &Path, cultmesh_store: &Path, runtime_id: &str, - status: &str, + status: PersonaChatStatus, reason: &str, ) -> Result { ensure_content(content, "Persona chat")?; @@ -152,7 +152,7 @@ fn run_draft( )?; let path = write_draft(content, config, artifact_dir, status, reason, Some(&audit))?; Ok(serde_json::json!({ - "ok": status != "blocked", + "ok": status != PersonaChatStatus::Blocked, "posted": false, "draftPath": path, "speechAudit": audit, @@ -221,7 +221,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, &format!("speech audit blocked: {}", audit.reasons.join("; ")), Some(&audit), )?; @@ -238,7 +238,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "missing #aquarium channel id", Some(&audit), )?; @@ -255,7 +255,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "requested channel does not match configured #aquarium channel id", Some(&audit), )?; @@ -272,7 +272,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "missing Bifrost bridge CLI path", Some(&audit), )?; @@ -289,7 +289,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "missing Bifrost identity for public Persona crossing", Some(&audit), )?; @@ -306,7 +306,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "missing Heimdall-backed Discord capability/account reference for public Persona crossing; register the Bifrost identity, link it to Heimdall, and provide a heimdall:discord:* reference", Some(&audit), )?; @@ -323,7 +323,7 @@ fn run_post( content, config, artifact_dir, - "blocked", + PersonaChatStatus::Blocked, "Heimdall reference must be shaped for Discord public Persona crossings: heimdall:discord:*", Some(&audit), )?; @@ -351,7 +351,7 @@ fn run_post( content, config, artifact_dir, - "posted", + PersonaChatStatus::Posted, &format!( "posted message {} through {}", posted.message_id, posted.transport @@ -430,6 +430,31 @@ struct PersonaSpeechAudit { audit_path: PathBuf, } +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum PersonaChatStatus { + Draft, + Blocked, + Posted, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersonaChatArtifact { + schema_version: String, + created_at: String, + status: PersonaChatStatus, + reason: String, + allowed_channel_name: String, + allowed_channel_id: Option, + persona_name: Option, + persona_avatar_url: Option, + content: String, + speech_audit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bifrost_bridge_receipt: Option, +} + #[derive(Clone, Debug)] struct RecentPersonaSpeech { opening_key: String, @@ -837,7 +862,7 @@ console.log(JSON.stringify({ &temp_dir, &cultmesh_store, runtime_id, - "draft", + PersonaChatStatus::Draft, "drafted without posting", )?; let bubble = run_bubble( @@ -942,7 +967,7 @@ console.log(JSON.stringify({ "Rite noted: Modeling and Soul keep circling the same evidence seam.", &config, &temp_dir, - "posted", + PersonaChatStatus::Posted, "seed prior posted Persona output for speech-audit smoke", Some(&repeated_audit_seed), )?; @@ -954,7 +979,7 @@ console.log(JSON.stringify({ "Rite noted: Modeling and Soul keep circling the same evidence seam.", &config, &temp_dir, - "posted", + PersonaChatStatus::Posted, "seed second prior posted Persona output for speech-audit smoke", Some(&repeated_audit_seed), )?; @@ -972,7 +997,19 @@ console.log(JSON.stringify({ let latest_cultmesh_audit = load_latest_epiphany_cultmesh_persona_speech_audit(&cultmesh_store, runtime_id)? .context("Persona speech audit smoke expected latest CultMesh audit")?; + let draft_artifact: Value = serde_json::from_str(&fs::read_to_string( + draft["draftPath"] + .as_str() + .context("draft smoke result missing artifact path")?, + )?)?; + let posted_artifact: Value = serde_json::from_str(&fs::read_to_string( + bridged["draftPath"] + .as_str() + .context("posted smoke result missing artifact path")?, + )?)?; let ok = draft["ok"] == true + && chat_artifact_has_contract(&draft_artifact, false) + && chat_artifact_has_contract(&posted_artifact, true) && bubble["ok"] == true && bubble["bubble"]["schemaVersion"] == BUBBLE_SCHEMA_VERSION && bubble["bubble"].as_object().is_some_and(|payload| { @@ -1044,36 +1081,44 @@ fn write_draft( content: &str, config: &PersonaConfig, artifact_dir: &Path, - status: &str, + status: PersonaChatStatus, reason: &str, speech_audit: Option<&PersonaSpeechAudit>, ) -> Result { - let payload = serde_json::json!({ - "schema_version": CHAT_SCHEMA_VERSION, - "created_at": now_iso(), - "status": status, - "reason": reason, - "allowed_channel_name": if config.allowed_channel_name.is_empty() { "#aquarium" } else { &config.allowed_channel_name }, - "allowed_channel_id": allowed_channel_id(config), - "persona_name": config.persona_name, - "persona_avatar_url": config.persona_avatar_url, - "content": content.trim(), - "speechAudit": speech_audit, - }); + let payload = PersonaChatArtifact { + schema_version: CHAT_SCHEMA_VERSION.to_string(), + created_at: now_iso(), + status, + reason: reason.to_string(), + allowed_channel_name: if config.allowed_channel_name.is_empty() { + "#aquarium".to_string() + } else { + config.allowed_channel_name.clone() + }, + allowed_channel_id: allowed_channel_id(config), + persona_name: config.persona_name.clone(), + persona_avatar_url: config.persona_avatar_url.clone(), + content: content.trim().to_string(), + speech_audit: speech_audit.cloned(), + bifrost_bridge_receipt: None, + }; let path = artifact_dir.join(format!("Persona-chat-{}-{}.json", now_stamp(), short_id())); - write_json(&path, &payload)?; + write_json(&path, &serde_json::to_value(payload)?)?; Ok(path) } fn bind_publication_receipt(path: &Path, receipt: &Value) -> Result<()> { - let mut payload: Value = serde_json::from_str(&fs::read_to_string(path)?)?; - payload["bifrostBridgeReceipt"] = receipt.clone(); - write_json(path, &payload) + let mut payload: PersonaChatArtifact = serde_json::from_str(&fs::read_to_string(path)?)?; + payload.bifrost_bridge_receipt = Some(receipt.clone()); + write_json(path, &serde_json::to_value(payload)?) } fn has_bound_discord_publication(payload: &Value) -> bool { let receipt = &payload["bifrostBridgeReceipt"]; - let channel_id = payload["allowed_channel_id"].as_str().unwrap_or_default(); + let channel_id = payload["allowedChannelId"] + .as_str() + .or_else(|| payload["allowed_channel_id"].as_str()) + .unwrap_or_default(); let audit_id = payload["speechAudit"]["auditId"] .as_str() .or_else(|| payload["speechAudit"]["audit_id"].as_str()) @@ -1086,6 +1131,31 @@ fn has_bound_discord_publication(payload: &Value) -> bool { && receipt["provenance"]["sourceId"].as_str() == Some(audit_id) } +fn chat_artifact_has_contract(payload: &Value, expects_receipt: bool) -> bool { + const REQUIRED: [&str; 10] = [ + "allowedChannelId", + "allowedChannelName", + "content", + "createdAt", + "personaAvatarUrl", + "personaName", + "reason", + "schemaVersion", + "speechAudit", + "status", + ]; + payload.as_object().is_some_and(|object| { + let expected_len = REQUIRED.len() + usize::from(expects_receipt); + object.len() == expected_len + && REQUIRED.iter().all(|key| object.contains_key(*key)) + && object.contains_key("bifrostBridgeReceipt") == expects_receipt + && matches!( + payload["status"].as_str(), + Some("draft" | "blocked" | "posted") + ) + }) +} + fn bubble_payload(content: &str, source: &str, mood: &str) -> Value { serde_json::json!({ "schemaVersion": BUBBLE_SCHEMA_VERSION, @@ -1133,9 +1203,7 @@ fn latest_persona_artifacts(artifact_dir: &Path, limit: usize) -> Vec { "path": path, "name": path.file_name().and_then(|name| name.to_str()), "modifiedAt": modified_at.to_rfc3339_opts(SecondsFormat::Secs, true), - "schemaVersion": payload - .get("schemaVersion") - .or_else(|| payload.get("schema_version")), + "schemaVersion": payload.get("schemaVersion").or_else(|| payload.get("schema_version")), "status": payload.get("status"), "reason": payload.get("reason"), "content": payload.get("content"), @@ -1185,7 +1253,10 @@ fn recent_persona_speech(artifact_dir: &Path, limit: usize) -> Vec0c$POp0>X%tR5Ey$UO``xt2oF|iS$s7k63$ki1w zCW@dLi!^>^y_%EmAxMwF$;k*Y5UCQd)@I|uhmHp=JT}FWJ76Y7CZv(Pye1{r)42oy zg(0isXk#1`T0es)2jhUk>U;wgCJ|00f%Ofs5|47mQVvgPhN^|=a857#?<|gu>%dUo z@y@n697(Q+3xWd^m1hmH@^gX(TBCa?I`2~J(RvGAHLo&*i)x(R%R?@WbALA%G5rUi4llFG&2yh05K~Nvu&Ts I#IBkG0Bx5N)&Kwi diff --git a/state/map.yaml b/state/map.yaml index 5002c939..4a788dee 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -661,4 +661,4 @@ current_status: - Service lifecycle receipt history is now event-only: `load_epiphany_cultmesh_daemon_service_lifecycle_receipts` filters out the `epiphany-local/daemon-service-lifecycle-receipt/latest` mirror, while `load_latest_epiphany_cultmesh_daemon_service_lifecycle_receipt` remains the latest-state reader. Focused unit test `service_lifecycle_receipt_history_excludes_latest_mirror` proves two per-receipt writes return exactly two history records and latest points at the second. Fresh smoke `verse-query-lifecycle-history-filter-smoke` and live overview/triage `local-20260618-145632-497-efac6f0d` / `local-20260618-145632-321-867947af` prove compact swarm sights still expose service lifecycle attention plus the present elevated runbook artifact without daemon pokes or service mutation. Do not use a broad unscoped cargo test filter for this check on Windows; it fans out across bin harnesses and hit a broken pipe during this pass. - `epiphany-verse-query seed-compact` is the compact seed receipt for wrappers and read-only rites. The older `seed` command still prints the full local Verse context for explicit debugging, but compact wrapper modes now avoid full `seed`/final `query` readback after the combined overview proof exposed Windows debug stack overflow from full-context serialization pressure. last_completed_repo_swarm_cut: Huginn live-fire has crossed the first real repo Body from Persona pressure to branch-local Hands commit and Soul/Mind closure, and now has repeatable closure and redacted-public-proof mouths. On `E:\Projects\Huginn` branch `epiphany/huginn/first-awakening`, `tools/epiphany_local_run.ps1 -Mode repo-persona-intake` accepted item `huginn-inspection-readback-livefire`, Weksa/Persona intake remained sealed, and `epiphany-work derive-plan` lowered it to model-authored safe family `repo.status_section` for `README.md` only. `tools/epiphany_local_run.ps1 -Mode repo-swarm-run` advanced four bounded pulses: run-from-plan, adopt-from-plan, execute-from-plan, and close-from-execute. Huginn commit `e9b07c38fceabbdc89ea35c2156326c80c0393a2` updated only `README.md`; closure receipt `.epiphany\work\work-close-huginn-inspection-readback-livefire.json` records status `closed`, Soul verdict `passed`, Mind state commit `repo-work-close-huginn-inspection-readback-livefire-mind-commit`, safe family `repo.status_section`, path scope `README.md`, Bifrost publication gate, and `privateStateExposed=false`. Verification also ran Huginn's TypeScript compiler through concrete Node path: `C:\Program Files\nodejs\node.exe E:\Projects\Huginn\node_modules\typescript\bin\tsc -p tsconfig.json` passed. Native verifier `epiphany-repo-livefire-closure-smoke --workspace E:\Projects\Huginn --item huginn-inspection-readback-livefire --expected-path README.md --expected-family repo.status_section --expected-gate awaiting-publication --expected-blocker bifrost-publication-missing` checks closure receipt, Mind map entry, overview gate/blocker, authority seals, and `git show` changed paths, returning `status=ok`, `soulVerdict=passed`, `publicationGate=Bifrost`, and `privateStateExposed=false`. Wrapper `tools/epiphany_local_run.ps1 -Mode repo-livefire-closure -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exposes the same read-only proof through the local operator mouth; artifact `local-20260621-190156-803-2f85ba15` printed commit, path, gate, blocker, Soul verdict, Mind commit receipt, and `privateStateExposed=False` without push, merge, publication, or target mutation. New wrapper `tools/epiphany_local_run.ps1 -Mode repo-work-public-proof -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exports the redacted proof bundle and repo-local Verse index without publishing; artifact `local-20260621-190645-345-4555b1dc` wrote `.epiphany\public\proof-bundles\repo-work-public-proof-huginn-inspection-readback-livefire.json`, proof id `repo-work-public-proof-huginn-inspection-readback-livefire`, SHA-256 `857545b199f3e37eb06c2e757e5cfc201958e46667bb1f8403fd333a22620f63`, nine artifact rows, zero publication rows, and `privateStateExposed=False`. swarm overview readback `local-20260621-190701-140-b9519ec6` sees the `REPO-WORK-PUBLIC-PROOF` row and action row from Huginn's repo-local Verse with the same SHA-256 while Bifrost publication remains missing. Launcher compact modes are now named compact operator-context modes instead of read-only modes, and `bifrost-public-proof` summaries print item, target public Verse, room, proof SHA-256, review receipts, and credit receipts so publication receipts are auditable without claiming upstream sync. - next_action: Continue the observation-to-consequence audit using live organ-shaped prompt probes. Preserve the Aetheria dogfood lesson: Modeling's typed persistent anatomy/freshness/frontier/index knowledgebase is the targeting organ for Hands, while Eyes, Soul, Imagination, and Self retain distinct evidence, verification, future-shape, and routing authority. Bifrost command cardinality is now source-grounded as an upstream wound: ambient command-id strings are not typed one-shot crossing commands, and Epiphany must not invent local authority. Bubble and character artifacts now match their strict registered root contracts. Next audit the remaining artifact/schema pairs and prompt projections for catalog theater, caller-authored consequence, or transient coordinator knowledge masquerading as persistent Modeling state. Keep closure's zero-substring invariant. + next_action: Continue the observation-to-consequence audit using live organ-shaped prompt probes. Preserve the Aetheria dogfood lesson: Modeling's typed persistent anatomy/freshness/frontier/index knowledgebase is the targeting organ for Hands. Bubble, character, and Persona chat artifacts now match strict registered contracts; chat lifecycle is a closed typed producer decision and legacy snake_case is read-only. Next rebuild or delete the fictional `epiphany.surface.persona.v0` advertisement: it has no schema-versioned producer and untyped artifact arrays, while real Reddit and Other outputs lack registered contracts. The coherent surface should carry discriminated typed artifact references without gaining mouth eligibility, Mind admission, Bifrost publication, or provider-delivery authority. Keep closure's zero-substring invariant.