Require bound Persona publication receipts

This commit is contained in:
Meta Vens 2026-07-13 17:38:54 +01:00
commit 09e562e34b
6 changed files with 313 additions and 12 deletions

View file

@ -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<RecentPersona
let status = payload["status"].as_str().unwrap_or_default();
let action_kind = if payload["schema_version"] == BUBBLE_SCHEMA_VERSION {
PersonaSpeechActionKind::Bubble
} else if status == "posted" {
} else if status == "posted" && has_bound_discord_publication(&payload) {
PersonaSpeechActionKind::Post
} else {
PersonaSpeechActionKind::Draft

View file

@ -443,7 +443,92 @@ fn post_bifrost_reddit_thread(
));
}
let stdout = String::from_utf8(output.stdout).context("Bifrost bridge stdout was not UTF-8")?;
serde_json::from_str(strip_bom(&stdout)).context("Bifrost bridge stdout was not JSON")
let receipt: Value =
serde_json::from_str(strip_bom(&stdout)).context("Bifrost bridge stdout was not JSON")?;
validate_reddit_publication_receipt(
&receipt,
config,
bifrost_identity,
heimdall_capability_ref,
subreddit,
audit,
)?;
Ok(receipt)
}
fn validate_reddit_publication_receipt(
receipt: &Value,
config: &PersonaRedditConfig,
bifrost_identity: &str,
heimdall_capability_ref: &str,
subreddit: &str,
audit: &PersonaSpeechAudit,
) -> 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<RecentPersona
opening_key: opening_key(&title),
topic_key: topic_key(&format!("{title}\n\n{content}")),
subreddit: payload["subreddit"].as_str().map(str::to_string),
posted: payload["status"].as_str() == Some("posted"),
posted: payload["status"].as_str() == Some("posted")
&& has_bound_reddit_publication(&payload),
})
})
.collect()
}
fn has_bound_reddit_publication(payload: &Value) -> 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"));

View file

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

View file

@ -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_<command>` receipt.

Binary file not shown.

View file

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