Type the Persona chat artifact contract

This commit is contained in:
Meta Vens 2026-07-13 17:58:35 +01:00
commit 7e4564a34f
7 changed files with 169 additions and 44 deletions

View file

@ -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<Value> {
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<String>,
persona_name: Option<String>,
persona_avatar_url: Option<String>,
content: String,
speech_audit: Option<PersonaSpeechAudit>,
#[serde(skip_serializing_if = "Option::is_none")]
bifrost_bridge_receipt: Option<Value>,
}
#[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<PathBuf> {
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<Value> {
"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) -> Vec<RecentPersona
Some(RecentPersonaSpeech {
opening_key: opening_key(&content),
topic_key: topic_key(&content),
channel_id: payload["allowed_channel_id"].as_str().map(str::to_string),
channel_id: payload["allowedChannelId"]
.as_str()
.or_else(|| payload["allowed_channel_id"].as_str())
.map(str::to_string),
action_kind,
})
})

View file

@ -1619,3 +1619,36 @@ snake_case remains read-only compatibility input. Bubble and character packet
schemas reject extra root fields, character intent rejects undeclared cargo,
and native smokes assert the exact emitted top-level sets. A registered schema
that the producer does not actually speak is catalog theater, not a contract.
The adjacent Persona chat/post artifact had the same wound with more authority
attached. Its live writer emitted snake_case fields plus speech-audit and
Bifrost-receipt evidence while the registered schema required camelCase, omitted
the evidence, accepted arbitrary lifecycle strings, and hid every disagreement
behind `additionalProperties=true`.
Authority map for the cut:
- Owner: the Persona Discord artifact writer owns local draft/post artifact
shape; Bifrost owns the publication receipt embedded after validated crossing.
- Inputs: audited content, fixed channel/Persona configuration, internally
derived draft/blocked/posted outcome, and optional validated Bifrost receipt.
- Outputs: one typed `epiphany.persona_chat.v0` artifact.
- Derived state: latest-artifact and repetition views are read-only projections.
- Forbidden writers: callers, generic surface schemas, MVP aggregation, and
audit telemetry cannot author lifecycle or publication.
- Cut line: delete the snake_case writer vocabulary and arbitrary status string;
retain snake_case only in legacy readers.
The writer is now a typed Rust `PersonaChatArtifact` with a closed
`PersonaChatStatus` enum. The registered schema names every emitted root field,
requires the speech audit, admits the Bifrost receipt only as optional provider
evidence, rejects extra fields, and uses the same lowercase schema identity and
catalog path as the file. The native smoke loads both draft and posted artifacts
and checks their exact top-level field sets.
Proprioception found the next Modeling frontier: `epiphany.surface.persona.v0`
is effectively catalog fiction. Its schema requires only `ok`, accepts untyped
artifact arrays, and no producer writes a document with that schema version.
Real Reddit and Other artifacts exist but are not registered as output
contracts. Rebuild that surface around typed discriminated artifact references,
register the real outputs, or stop advertising them as typed surface outputs.

View file

@ -27,6 +27,16 @@ and be projected into later Hands prompts. Otherwise an excellent coordinator
can perform the structure transiently, but the next awakened Self inherits only
the applause.
The next artifact-contract audit then cut `epiphany.persona_chat.v0`: its live
writer and catalog had disagreed on field vocabulary, evidence fields, and
lifecycle closure. The writer is now typed Rust with closed
draft/blocked/posted status, the schema is strict and complete, and legacy
snake_case survives only on read. Proprioception identified the next larger
frontier: `epiphany.surface.persona.v0` has no real schema-versioned producer and
types almost nothing, while Reddit and Other emit real artifacts absent from
the catalog. Rebuild that surface around discriminated typed artifact refs or
delete the false advertisement.
Epiphany is in an authority-provenance purification pass. The live question is not whether a command can produce a plausible document; it is whether the subsystem writing that document owns the fact it asserts.
The confirmed conceptual substitutions have been cut:

View file

@ -1,10 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.Persona-chat.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.persona-chat.schema.json",
"title": "Epiphany Persona Chat Draft",
"description": "Persona chat draft/post artifact.",
"type": "object",
"additionalProperties": true,
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "string"
@ -13,7 +13,8 @@
"type": "string"
},
"status": {
"type": "string"
"type": "string",
"enum": ["draft", "blocked", "posted"]
},
"reason": {
"type": "string"
@ -41,6 +42,12 @@
},
"content": {
"type": "string"
},
"speechAudit": {
"type": ["object", "null"]
},
"bifrostBridgeReceipt": {
"type": "object"
}
},
"required": [
@ -49,6 +56,10 @@
"status",
"reason",
"allowedChannelName",
"content"
"allowedChannelId",
"personaName",
"personaAvatarUrl",
"content",
"speechAudit"
]
}

View file

@ -835,10 +835,10 @@
"schemaVersion": "epiphany.persona_bubble.v0",
"documentType": "epiphany.persona_bubble.v0",
"title": "Epiphany Persona Bubble",
"path": "epiphany.Persona-bubble.schema.json"
"path": "epiphany.persona-bubble.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.Persona-chat.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.persona-chat.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
@ -846,7 +846,7 @@
"schemaVersion": "epiphany.persona_chat.v0",
"documentType": "epiphany.persona_chat.v0",
"title": "Epiphany Persona Chat Draft",
"path": "epiphany.Persona-chat.schema.json"
"path": "epiphany.persona-chat.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.character-turn-packet.schema.json",

Binary file not shown.

View file

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