diff --git a/epiphany-core/src/bin/epiphany-persona-discord.rs b/epiphany-core/src/bin/epiphany-persona-discord.rs index 66a5de35..a67fb60c 100644 --- a/epiphany-core/src/bin/epiphany-persona-discord.rs +++ b/epiphany-core/src/bin/epiphany-persona-discord.rs @@ -358,6 +358,7 @@ fn run_post( ), Some(&audit), )?; + bind_publication_receipt(&path, &posted.bridge_receipt)?; Ok(serde_json::json!({ "ok": true, "posted": true, @@ -647,10 +648,17 @@ fn post_bifrost_discord_message( let stdout = String::from_utf8(output.stdout).context("Bifrost bridge stdout was not UTF-8")?; let receipt: Value = serde_json::from_str(strip_bom(&stdout)).context("Bifrost bridge stdout was not JSON")?; + validate_discord_publication_receipt( + &receipt, + config, + bifrost_identity, + heimdall_capability_ref, + channel_id, + audit, + )?; let message_id = receipt["messageId"] .as_str() - .or_else(|| receipt["externalReceiptId"].as_str()) - .unwrap_or("bifrost-discord-post") + .expect("validated Discord receipt has messageId") .to_string(); Ok(PostedDiscordMessage { message_id, @@ -661,6 +669,87 @@ fn post_bifrost_discord_message( }) } +fn validate_discord_publication_receipt( + receipt: &Value, + config: &PersonaConfig, + bifrost_identity: &str, + heimdall_capability_ref: &str, + channel_id: &str, + audit: &PersonaSpeechAudit, +) -> Result<()> { + let expected_source_kind = config + .bifrost_source_kind + .as_deref() + .unwrap_or("epiphany_persona_speech"); + let expected_authority = config + .bifrost_authority_ref + .as_deref() + .unwrap_or("epiphany.persona_speech_audit"); + let checks = [ + (receipt["action"].as_str() == Some("discord-post"), "action"), + (receipt["ok"].as_bool() == Some(true), "successful outcome"), + ( + receipt["channelId"].as_str() == Some(channel_id), + "channel binding", + ), + (non_empty(&receipt["messageId"]), "provider message id"), + (non_empty(&receipt["transport"]), "provider transport"), + (non_empty(&receipt["url"]), "receipt URL"), + ( + non_empty(&receipt["crossingReceiptId"]), + "canonical crossing receipt id", + ), + ( + receipt["provenance"]["bifrostIdentity"].as_str() == Some(bifrost_identity), + "Bifrost identity", + ), + ( + receipt["provenance"]["sourceKind"].as_str() == Some(expected_source_kind), + "source kind", + ), + ( + receipt["provenance"]["sourceId"].as_str() == Some(audit.audit_id.as_str()), + "speech audit binding", + ), + ( + receipt["provenance"]["authorityReference"].as_str() == Some(expected_authority), + "authority reference", + ), + ( + receipt["provenance"]["epiphanyLaneId"].as_str() == Some("Persona"), + "lane identity", + ), + ( + receipt["provenance"]["epiphanyAgentIdentity"].as_str() == Some("epiphany.Persona"), + "agent identity", + ), + ( + receipt["provenance"]["heimdallCapabilityRef"].as_str() + == Some(heimdall_capability_ref), + "Heimdall capability", + ), + ]; + for (valid, label) in checks { + if !valid { + return Err(anyhow!( + "Bifrost Discord publication receipt missing or mismatched {label}" + )); + } + } + let message_id = receipt["messageId"].as_str().expect("validated message id"); + let url = receipt["url"].as_str().expect("validated receipt URL"); + if !url.contains(&format!("/{channel_id}/{message_id}")) { + return Err(anyhow!( + "Bifrost Discord publication receipt URL is not bound to channel and message" + )); + } + Ok(()) +} + +fn non_empty(value: &Value) -> bool { + value.as_str().is_some_and(|value| !value.trim().is_empty()) +} + fn bifrost_node_executable() -> String { env::var("EPIPHANY_BIFROST_NODE") .ok() @@ -696,8 +785,12 @@ function opt(name) { } console.log(JSON.stringify({ action: args[0], + ok: true, + channelId: opt("--channel-id"), messageId: "bridge-message-123", externalReceiptId: "bridge-message-123", + transport: "discord-webhook", + crossingReceiptId: "crossing_smoke-discord-post", url: `https://discord.com/channels/test/${opt("--channel-id")}/bridge-message-123`, provenance: { bifrostIdentity: opt("--identity"), @@ -715,6 +808,10 @@ console.log(JSON.stringify({ .with_context(|| format!("failed to write {}", fake_bridge.display()))?; let mut bridge_config = config.clone(); bridge_config.bifrost_bridge_cli_path = Some(fake_bridge); + let invalid_bridge = temp_dir.join("invalid-bifrost-bridge.mjs"); + fs::write(&invalid_bridge, "console.log('{}');\n")?; + let mut invalid_receipt_config = bridge_config.clone(); + invalid_receipt_config.bifrost_bridge_cli_path = Some(invalid_bridge); let mut missing_capability_config = bridge_config.clone(); missing_capability_config.heimdall_capability_ref_env = Some("HEIMDALL_CAPABILITY_REF_MISSING_FOR_SMOKE".to_string()); @@ -809,6 +906,18 @@ console.log(JSON.stringify({ Some("https://example.invalid/Persona.png".to_string()), None, )?; + let invalid_receipt_rejected = run_post( + "Persona must reject empty successful bridge JSON.", + &invalid_receipt_config, + &temp_dir, + &cultmesh_store, + runtime_id, + Some("123".to_string()), + None, + None, + None, + ) + .is_err(); let wrong = run_post( "Persona should not post outside #aquarium.", &bridge_config, @@ -837,6 +946,10 @@ console.log(JSON.stringify({ "seed prior posted Persona output for speech-audit smoke", Some(&repeated_audit_seed), )?; + let verified_post_count = recent_persona_speech(&temp_dir, 12) + .iter() + .filter(|speech| speech.action_kind == PersonaSpeechActionKind::Post) + .count(); write_draft( "Rite noted: Modeling and Soul keep circling the same evidence seam.", &config, @@ -872,6 +985,8 @@ console.log(JSON.stringify({ && wrong_surface_capability["ok"] == false && wrong_surface_capability["blocked"] == "wrong-heimdall-capability-surface" && bridged["ok"] == true + && invalid_receipt_rejected + && verified_post_count == 1 && bridged["transport"] == "bifrost.discord-post" && bridged["bifrostBridgeReceipt"]["provenance"]["bifrostIdentity"] == "epiphany.Persona" && bridged["bifrostBridgeReceipt"]["provenance"]["heimdallCapabilityRef"] @@ -899,6 +1014,8 @@ console.log(JSON.stringify({ "missingCapability": missing_capability, "wrongSurfaceCapability": wrong_surface_capability, "bridged": bridged, + "invalidReceiptRejected": invalid_receipt_rejected, + "verifiedPostCount": verified_post_count, "wrongChannel": wrong, "repeatedSpeech": repeated, "latestCultMeshSpeechAudit": latest_cultmesh_audit, @@ -932,6 +1049,27 @@ fn write_draft( 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) +} + +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 audit_id = payload["speechAudit"]["auditId"] + .as_str() + .or_else(|| payload["speechAudit"]["audit_id"].as_str()) + .unwrap_or_default(); + receipt["action"] == "discord-post" + && receipt["ok"] == true + && receipt["channelId"].as_str() == Some(channel_id) + && non_empty(&receipt["messageId"]) + && non_empty(&receipt["crossingReceiptId"]) + && receipt["provenance"]["sourceId"].as_str() == Some(audit_id) +} + fn bubble_payload(content: &str, source: &str, mood: &str) -> Value { serde_json::json!({ "schema_version": BUBBLE_SCHEMA_VERSION, @@ -1019,7 +1157,7 @@ fn recent_persona_speech(artifact_dir: &Path, limit: usize) -> Vec Result<()> { + let expected_source_kind = config + .bifrost_source_kind + .as_deref() + .unwrap_or("epiphany_persona_reddit"); + let expected_authority = config + .bifrost_authority_ref + .as_deref() + .unwrap_or("epiphany.persona_speech_audit"); + let checks = [ + (receipt["action"].as_str() == Some("reddit-post"), "action"), + (receipt["ok"].as_bool() == Some(true), "successful outcome"), + ( + receipt["subreddit"].as_str() == Some(subreddit), + "subreddit binding", + ), + ( + non_empty(&receipt["thingId"]) || non_empty(&receipt["url"]), + "provider thing id or receipt URL", + ), + ( + non_empty(&receipt["crossingReceiptId"]), + "canonical crossing receipt id", + ), + ( + receipt["provenance"]["bifrostIdentity"].as_str() == Some(bifrost_identity), + "Bifrost identity", + ), + ( + receipt["provenance"]["sourceKind"].as_str() == Some(expected_source_kind), + "source kind", + ), + ( + receipt["provenance"]["sourceId"].as_str() == Some(audit.audit_id.as_str()), + "speech audit binding", + ), + ( + receipt["provenance"]["authorityReference"].as_str() == Some(expected_authority), + "authority reference", + ), + ( + receipt["provenance"]["epiphanyLaneId"].as_str() == Some("Persona"), + "lane identity", + ), + ( + receipt["provenance"]["epiphanyAgentIdentity"].as_str() == Some("epiphany.Persona"), + "agent identity", + ), + ( + receipt["provenance"]["heimdallCapabilityRef"].as_str() + == Some(heimdall_capability_ref), + "Heimdall capability", + ), + ]; + for (valid, label) in checks { + if !valid { + return Err(anyhow!( + "Bifrost Reddit publication receipt missing or mismatched {label}" + )); + } + } + Ok(()) +} + +fn non_empty(value: &Value) -> bool { + value.as_str().is_some_and(|value| !value.trim().is_empty()) } fn audit_persona_speech( @@ -574,6 +659,7 @@ console.log(JSON.stringify({ personaFlairText: opt("--persona-flair-text"), thingId: "t3_epiphanysmoke", url: `https://reddit.com/r/${opt("--subreddit")}/comments/t3_epiphanysmoke/epiphany_smoke/`, + crossingReceiptId: "crossing_smoke-reddit-post", provenance: { bifrostIdentity: opt("--identity"), sourceKind: opt("--source-kind"), @@ -599,6 +685,12 @@ console.log(JSON.stringify({ bifrost_authority_ref: Some("epiphany.persona_speech_audit".to_string()), heimdall_capability_ref_env: Some("HEIMDALL_CAPABILITY_REF_TEST".to_string()), }; + let invalid_bridge = temp_dir.join("invalid-bifrost-bridge.mjs"); + fs::write(&invalid_bridge, "console.log('{}');\n")?; + let invalid_receipt_config = PersonaRedditConfig { + bifrost_bridge_cli_path: Some(invalid_bridge), + ..config.clone() + }; let no_bridge_config = PersonaRedditConfig { bifrost_bridge_cli_path: None, ..config.clone() @@ -683,6 +775,19 @@ console.log(JSON.stringify({ None, None, )?; + let invalid_receipt_rejected = run_post( + "Epiphany rejects empty bridge JSON", + "Persona must not call arbitrary JSON a Reddit publication receipt.", + &invalid_receipt_config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + None, + None, + ) + .is_err(); let seed = audit_persona_speech( "Repeated Reddit rite", "Modeling and Soul keep circling the same public proof seam.", @@ -702,6 +807,10 @@ console.log(JSON.stringify({ Some(&seed), None, )?; + let verified_post_count = recent_persona_speech(&temp_dir, 12) + .iter() + .filter(|speech| speech.posted) + .count(); write_post_artifact( "Repeated Reddit rite", "Modeling and Soul keep circling the same public proof seam.", @@ -735,6 +844,8 @@ console.log(JSON.stringify({ && wrong_surface_capability["ok"] == false && wrong_surface_capability["blocked"] == "wrong-heimdall-capability-surface" && bridged["ok"] == true + && invalid_receipt_rejected + && verified_post_count == 1 && bridged["transport"] == "bifrost.reddit-post" && bridged["bifrostBridgeReceipt"]["provenance"]["bifrostIdentity"] == "epiphany.Persona" && bridged["bifrostBridgeReceipt"]["provenance"]["heimdallCapabilityRef"] @@ -753,6 +864,8 @@ console.log(JSON.stringify({ "missingCapability": missing_capability, "wrongSurfaceCapability": wrong_surface_capability, "bridged": bridged, + "invalidReceiptRejected": invalid_receipt_rejected, + "verifiedPostCount": verified_post_count, "repeatedSpeech": repeated, "latestCultMeshSpeechAudit": latest_cultmesh_audit, }); @@ -854,12 +967,28 @@ fn recent_persona_speech(artifact_dir: &Path, limit: usize) -> Vec bool { + let receipt = &payload["bifrostBridgeReceipt"]; + let subreddit = payload["subreddit"].as_str().unwrap_or_default(); + let audit_id = payload["speechAudit"]["auditId"] + .as_str() + .or_else(|| payload["speechAudit"]["audit_id"].as_str()) + .unwrap_or_default(); + receipt["action"] == "reddit-post" + && receipt["ok"] == true + && receipt["subreddit"].as_str() == Some(subreddit) + && (non_empty(&receipt["thingId"]) || non_empty(&receipt["url"])) + && non_empty(&receipt["crossingReceiptId"]) + && receipt["provenance"]["sourceId"].as_str() == Some(audit_id) +} + fn ensure_post(title: &str, content: &str) -> Result<()> { if title.trim().is_empty() { return Err(anyhow!("Persona Reddit title is empty")); diff --git a/notes/conceptual-substitution-audit-2026-07-12.md b/notes/conceptual-substitution-audit-2026-07-12.md index c5b74c41..654c4d2b 100644 --- a/notes/conceptual-substitution-audit-2026-07-12.md +++ b/notes/conceptual-substitution-audit-2026-07-12.md @@ -1549,7 +1549,27 @@ artifact reads. The stale caller is deleted and aggregate Persona state is candidate speech, local bubble, eligibility, Mind admission, and Bifrost/provider delivery are distinct consequences. -Prompt signal: Modeling currently requires a durable `statePatch` even when no -model change is warranted. That can manufacture persistence merely to satisfy -the output contract. The next prompt-contract pass should add a source-grounded -`no-state-change` result rather than encouraging ceremonial scratch churn. +Prompt signal, corrected after operator review: Modeling must always maintain +the live persistent body map. A source pass that confirms existing anatomy +still owes a typed freshness/checkpoint witness so Hands can trust the map +instead of rediscovering the repo. The prompt improvement is not a +`no-state-change` escape hatch; it is sharper language requiring every patch to +name changed, confirmed, or invalidated anatomy plus freshness/frontier and +semantic-index consequences, so mandatory persistence cannot decay into +ceremonial scratch churn. + +## Successful bridge process is not publication + +Eyes inspected Bifrost's actual bridge contract. Discord and Reddit stdout must +carry exact action, `ok=true`, destination binding, provider identity/URL, +canonical crossing receipt id, and provenance bound to the Persona speech audit, +lane, agent, authority, Bifrost identity, and Heimdall capability. Epiphany had +accepted exit zero plus arbitrary JSON; Discord even invented a fallback message +id. Both mouths now validate the existing Bifrost contract before writing +`posted`. Hostile exit-zero `{}` bridges are rejected by native smokes. + +Publication history no longer trusts local artifact `status=posted`. Discord +binds the validated receipt into its artifact; both history readers require a +receipt whose action, target, provider evidence, crossing id, and audit source id +match the artifact. Forged status-only artifacts remain readable local files but +cannot increase verified post counts or steer same-target publication pressure. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 9670f1de..7eb109f9 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -604,5 +604,19 @@ repo-intake consensus status argument and demoted aggregate Persona status from `ready` to `loaded`. Persona prompts now explicitly separate candidate, bubble, eligibility, Mind admission, and Bifrost/provider delivery ownership. Native negative/smoke checks, 259 library tests, 32 work tests, and all-bin compilation -pass. Next repair Modeling's forced-patch prompt pressure and the P0 Discord/ -Reddit arbitrary-JSON publication receipts found by Eyes and Soul. +pass. Operator correction: Modeling's mandatory typed patch is intentional +because the live searchable body map is its product; confirmation passes must +bank freshness and source-grounded map confidence. Improve the patch vocabulary, +do not add a no-state-change escape. Continue the P0 Discord/Reddit arbitrary- +JSON publication receipt cut found by Eyes and Soul. + +That P0 cut is complete. Epiphany now validates Bifrost's real Discord/Reddit +publication output: exact action/outcome, target, provider id or URL, canonical +crossing receipt id, and returned provenance bound to the speech audit and +authority inputs. Discord fallback IDs are deleted. Native hostile smokes prove +exit-zero `{}` cannot publish. Posted artifacts count as history only when their +embedded receipt binds back to the artifact target and audit; forged +`status=posted` files do not count. The remaining Bifrost seam is command +cardinality: Epiphany inherits a CultMesh command id, so the coordinator must +ensure one typed command id per crossing rather than letting multiple posts share +one canonical `crossing_` receipt. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 0ee56010..399e5a66 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 09f0b08e..9748415c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -660,4 +660,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. Character-loop caller status and synthetic heartbeat physiology are cut; Persona prompts name candidate/bubble/eligibility/admission/delivery ownership. Next add a truthful Modeling `no-state-change` result so prompts do not force ceremonial persistence, then cut the P0 Discord/Reddit arbitrary-JSON publication receipts and forged local `status=posted` history identified independently by Eyes and Soul. Keep closure's zero-substring invariant. + next_action: Continue the observation-to-consequence audit using live organ-shaped prompt probes. Modeling must keep producing typed persistent body-map maintenance because that live semantic knowledgebase makes Hands laser-guided; confirmation passes should bank source-grounded freshness/frontier/index consequences. Discord/Reddit now require target- and audit-bound Bifrost publication evidence, and forged status-only history is inert. Next trace Bifrost CultMesh command-id cardinality so each crossing owns a unique canonical receipt, then align bubble/character artifact producers with their registered strict schemas. Keep closure's zero-substring invariant.