Local resource

Payload Contracts

_docs/PAYLOAD_CONTRACTS.md

Payload Contracts

Identity certainty boundary (wardian-core-identity.v2)

V2 is an additive, consumer-first upgrade over wardian-core-identity.v1. Core sets payload.context.identity_contract_version=wardian-core-identity.v2 and must also provide a strict payload.context.identity_certainty object with:

  • contract_version=wardian-identity-certainty.v1;
  • policy_version=wardian-person-attribution-policy.v1;
  • an opaque ics_* snapshot ref, timezone-aware captured_at, and non-negative
  • identity_generation;

  • default_attribution=abstain and mode allow_verified,
  • mixed_abstention, or abstain_all;

  • unique opaque subj_* subjects with scope, confidence, conflict, attribution,
  • allowlisted reason codes, and a positive affected-message count.

Only confidence=verified plus conflict=none may declare allow. Every other combination must declare abstain with a reason. A direct conversation requires conversation.metadata.identity_subject_ref; every contact-authored group message requires message.metadata.identity_subject_ref. Optional identity_attribution at those locations must match the subject decision. abstain_all cannot contain an allowed subject, and a direct message cannot override its conversation with a different subject ref. Direct bindings must reference direct_contact subjects and group-message bindings must reference group_participant subjects. Status, channel and unknown surfaces do not invent person subjects; their contact sender identity is sanitized under the default abstention policy.

Before prompt rendering, CIS removes person-bearing group participant metadata whenever any participant in that group is abstained. It also replaces affected raw chat ids and names inside user_stats_metadata with opaque/generic values. Abstained group documents use a snapshot- and group-scoped grp_* id, so the same unresolved participant cannot make two distinct groups collide. Abstained direct documents similarly scope their subj_* id by snapshot, subject and source conversation so unresolved aliases remain distinct.

Full results omit conversation_documents and media_processed whenever any identity abstention is active; sanitized report output and the fixed ACK remain.

The side contract never contains names, phones, JIDs, Core contact/message IDs, or raw conflict evidence. Missing bindings, duplicate refs, extra fields, inconsistent decisions, or a malformed snapshot fail closed with identity_certainty_invalid before any workflow or model call. Exact V1 remains accepted during N/N-1 rollout, but it has no certainty side contract. Any other explicit identity contract version is rejected rather than treated as legacy. For conversation-free interweek input with abstention, CIS anonymizes every chat/name label in user stats while retaining aggregate counters.

This document defines the payloads that WAHA Core, WArdian Core, broker workers and dev tools should send to the Content Intelligence Service (CIS).

For response builder behavior and flow outputs, see _docs/OUTPUT_CONTRACTS.md. For the exact runtime model requests derived from these payloads, see Runtime Prompt Requests.

Do Payloads Change By Flow?

The transport contract does not change by flow. Every run uses the same FlowRunRequest envelope:

  • flow: required flow id.
  • recipe: optional recipe id; omitted means the flow default.
  • settings: optional run settings.
  • payload: required data. It can be a raw WAHA event batch or a canonical
  • FlowPayload.

The canonical payload shape is stable across flows. What changes by flow is the expected time window, optional metadata usefulness, media recipe and output shape.

FlowExpected input windowFlow-specific input notesOutput
smoke-testMinimal valid conversation fixture.Dev/runtime health check only.output.smoke_test
initial-ingestion-reportHistorical import or configured initial snapshot. Send every conversation that belongs in the report.user_stats_metadata is read by the prompt when provided. No prior memories are required.output.report, output.chat_id_summaries, output.chat_id_memory_updates
daily-summary-and-notifications-reportDaily half-open window. Send every conversation/message that should be considered for that day.An empty conversations list is accepted with V1 legacy zero or valid V2 verified_zero/unverified_zero. Otherwise the existing conversation requirement remains.output.daily_summary, output.notifications, output.chat_id_memory_updates
interweek-reportSynthetic 3/4/x-day half-open window from existing CIS/Core context.Does not require raw conversations. V2 totals.activity_events: 0 plus source coverage, or the V1 root integer total_messages: 0 fallback, produces the corresponding deterministic zero report.output.interweek_report

Producers

ProducerPreferred payloadWhen to use
WAHA Core or webhook edgeRaw WAHA events with source: "waha" and events.The producer has WAHA events and wants CIS to normalize them.
WArdian Core or another internal moduleCanonical FlowPayload with conversations.Core already assembled chats/messages and selected the processing window.
Kafka brokerFlowRunEnvelope.payload containing the same FlowRunRequest, or FlowRunEnvelope.payload_ref pointing at a JSON artifact with that request.Async batch ingress; use payload_ref only when the inline envelope would exceed Kafka limits.
Dev tools/playgroundsCanonical FlowPayload or raw WAHA fixtures.Local validation and prompt iteration.

If payload.conversations is present, the WAHA adapter is skipped and CIS validates the payload directly as FlowPayload. If payload.source == "waha", or if the payload has event/events and no conversations, the WAHA adapter runs first.

Named Contract Versions

These are the named producer/result contracts that appear in CIS payloads, outputs, broker messages and prompt context.

wardian-core-identity.v1

Defined at payload.context.identity_contract_version. This is a marker, not a data container. When it is set to wardian-core-identity.v1, Core is sending identity metadata in the canonical FlowPayload shape:

  • Direct chat identity lives in conversation.metadata.direct_identity.
  • Contact-object identity lives in conversation.metadata.contact_identity.
  • Group identity lives in conversation.metadata.group_identity.
  • Group sender aliases live in conversation.metadata.group_participants.
  • Direct-chat shared group context lives in
  • conversation.metadata.shared_groups_with_contact.

The exact field-level contract is documented in Core identity metadata and rendered by Conversation Builder. Runtime examples in tests/fixtures/contracts/payload_initial_ingestion_report.json and tests/fixtures/contracts/payload_daily_summary_and_notifications_report.json exercise direct identity, contact identity, group identity, group participants and shared groups end to end.

wardian-user-stats.v1 / wardian-user-stats.v2

Defined at payload.user_stats_metadata.contract_version. CIS treats this as Core-computed aggregate context. CIS does not compute or backfill missing stats. The only stats-only report path is explicit supported zero activity: exact V1 plus root total_messages as JSON integer 0 preserves the legacy green path; valid V2 plus the same zero yields verified_zero, unverified_zero, planned_pause_full or planned_pause_partial from source_coverage.status. Missing, negative, positive, boolean, float or string values do not activate a deterministic-zero decision. The zero claim must agree with every supplied sibling aggregate: inbound, outbound, active-chat and media counters must be numeric zero; top-chat/contact collections must be empty; and night/message-type breakdowns may contain only zero counters and empty top-chat collections. Unknown or non-numeric night aggregate fields fail closed. Daily conversations and InterWeek period evidence (previous_daily_summaries / previous_notifications) must also be empty. CIS validates these contradictions during request normalization, before Temporal can fan out media work. When metadata and stats both provide window boundaries, each provided value must be a non-empty string and their parsed UTC instants must match, including across daylight-saving folds. A supplied nested window must be an object, and every supplied timezone must be a non-empty IANA timezone string. Recommended keys are documented in Canonical FlowPayload. For the legacy ranking objects, monitored_participation_ratio is exactly monitored_messages / total_messages when the denominator is positive. Runtime-validated examples:

  • tests/fixtures/contracts/payload_user_stats_v1_compat.json

wardian-user-stats.v2

The current Core-owned aggregate contract is strict and exhaustive. Initial, daily and InterWeek payload fixtures exercise it end to end:

  • tests/fixtures/contracts/payload_initial_ingestion_report.json
  • tests/fixtures/contracts/payload_daily_summary_and_notifications_report.json
  • tests/fixtures/contracts/payload_interweek_report.json

The root contains window, totals, rollups, by_type, view_once, interactions_by_type, coverage and every active direct/group chat in chats. A Counter is exactly {total, sent_by_monitored, received_by_monitored} with non-negative JSON integers and sent_by_monitored + received_by_monitored = total.

Validation before any prompt or Temporal work enforces:

  • totals.activity_events = messages.total + interaction_events.total and
  • totals.active_chats = len(chats);

  • exact type/rollup/global-to-chat reconciliation, unique non-empty chat IDs and
  • order by messages.total DESC, chat_id ASC;

  • reactions reconcile to interaction events; confirmed View Once reconciles by
  • type and stays within base message types; confirmed View Once plus detection unknown never exceeds base messages;

  • direct chats omit active_participants_count; groups include it; timestamps
  • are RFC3339, ordered and inside the half-open stats window;

  • when V2 coverage contains excluded_intervals, every exhaustive chat's
  • first_activity_at and last_activity_at must also fall outside those half-open intervals, or the payload fails as user_stats_invalid before a prompt is built;

  • the stats window matches supplied payload metadata boundaries/timezone;
  • coverage unknown/media counters reconcile to the typed message totals.

During rollout v2 may also carry the typed v1 projections window_start, window_end, timezone, inbound/outbound/total/media/active-chat counters, legacy night total and exhaustive top_chats_by_messages. When any projection field is present, all are required and must exactly reconcile to v2 (legacy participation percentage remains the truncated integer int(ratio * 100)).

Absent stats and exact v1 remain accepted. Any present non-object, missing or unknown contract version, malformed v2, contradictory zero-activity claim, or reconciliation failure is permanent user_stats_invalid. Kafka inline and payload_ref ingestion both publish a terminal failed result with retryable=false so Core receives the outcome. Identity-contract rejection after payload-ref hydration preserves the sanitized rejected snapshot ACK; structurally invalid envelopes without result-routing identifiers go to DLQ.

wardian-tutor-report-guidance.v1

Optional CP-owned report preference at payload.context.tutor_report_guidance. CP captures and persists it; CIS stays stateless and consumes only the explicit object received in each run.

{
  "contract_version": "wardian-tutor-report-guidance.v1",
  "source_kind": "text",
  "status": "provided",
  "report_priorities": ["priorizar cambios de humor"],
  "main_concerns": ["ansiedad escolar"],
  "watch_more": ["grupo del colegio"],
  "do_not_overinterpret": ["chistes entre amigos"],
  "freeform_summary": "Le preocupan examenes y aislamiento."
}
  • source_kind is text or voice_transcript. Both use this same v1 contract;
  • audio capture and transcription happen in the CP-owned onboarding path before report ingestion.

  • status is provided or omitted.
  • Each list accepts up to five unique string items after normalization, with up
  • to 120 characters per item. freeform_summary accepts up to 600 characters.

  • Runtime removes controls/newlines, collapses whitespace, bounds values,
  • deduplicates list items and drops unknown fields and invalid list elements.

  • A wrong contract version, source/status, container type, absent/omitted state
  • or empty normalized payload is ignored fail-open. It never fails the report and the raw object never falls through to generic prompt context.

  • A valid provided object renders as # Guia del tutor para reportes only in
  • initial, daily and InterWeek report prompts. chat_id_summarization never receives it.

  • The guidance is untrusted soft preference data: it may orient focus, order,
  • depth and explanation, but it is not evidence, a system instruction, a severity rule, an allowlist or a suppressor. It cannot create/suppress findings or affect chat_id_memory_updates.

wardian-audio-transcription-tool.v1

Private CP-to-CIS tool contract used only while BotFather is awaiting tutor report guidance. It does not change POST /analyze or the wardian-tutor-report-guidance.v1 report contract.

{
  "contract_version": "wardian-audio-transcription-tool.v1",
  "request_id": "tutor-guidance-<stable-id>",
  "purpose": "tutor_report_guidance",
  "tenant_id": "<tenant-id>",
  "onboarding_flow_id": "<flow-id>",
  "audio": {
    "source": "object_store",
    "bucket": "waha-media",
    "object_key": "session/chat/message.ogg",
    "mime_type": "audio/ogg",
    "size_bytes": 48123
  }
}

POST /v1/internal/audio-transcriptions requires its own bearer token, accepts only the configured WAHA bucket and object-store references, downloads to a bounded file under /tmp/wardian/, and deletes that file in finally. It never accepts media bytes or a URL. The response returns only status, normalized transcript, model, probed duration and a stable error code. Raw audio and transcript model I/O are forcibly excluded from Langfuse for this endpoint. The API also removes orphaned media from that temporary directory on startup, and rejects tool requests when the file duration cannot be verified locally.

CP converts a successful transcript to the existing wardian-tutor-report-guidance.v1 object with source_kind=voice_transcript; report flows therefore require no new external contract version.

wardian-monitored-age-band.v1

Optional CP-owned developmental context at payload.context.monitored_age_band. CP derives and snapshots it at the report window boundary; CIS never receives date of birth or exact age.

{
  "contract_version": "wardian-monitored-age-band.v1",
  "age_band": "13-15"
}
  • age_band is one of 10-12, 13-15, 16-17 or unknown.
  • unknown is a valid transport value for missing, invalid, future or
  • out-of-product-range DOB, but it renders no model-facing context.

  • Wrong versions, extra fields, invalid values and non-object values are
  • ignored fail-open. The raw key is always removed from generic context.

  • With MONITORED_AGE_BAND_CONTEXT_ENABLED=true, a known band renders once in
  • initial, daily and InterWeek report prompts. It never enters chat_id_summarization, conversation documents or memory updates.

  • The band is soft personalization context only: it may tune tone, complexity,
  • examples and recommendations. It is never evidence and cannot change risk, severity, confidence, taxonomy, alerts or memory decisions.

  • DOB/exact-age aliases (dob, birth*, nacimiento, age, edad) are
  • removed recursively from every model-facing metadata block in both privacy profiles.

conversation_memory.v2

Daily may add a canonical, stateless memory snapshot at payload.context.conversation_memory. CIS advertises the exact literal only in capabilities v4 while CIS_CONVERSATION_MEMORY_V2_MODE=observe|enforce:

{
  "contract_version": "conversation_memory.v2",
  "canonicalization_version": "wardian-json-c14n.v1",
  "slots": [
    {
      "chat_id": "chat-synthetic",
      "slot_key": "short_term_memory_recent_topics",
      "slot_value": {
        "summary": "Tema escolar previo sintético.",
        "active_threads": ["Esperar una devolución."],
        "relationship_context": "Contacto escolar sintético.",
        "open_questions": [],
        "last_observed_at": "2026-08-13"
      },
      "base_version": 7,
      "canonical_digest": "a27ac877e9e2c33910e3e82ea028a543f81942e2c9772a1fcfe8b18a3d7d5ced",
      "last_observed_at": "2026-08-13T18:00:00Z",
      "evidence_through": "2026-08-14T00:00:00Z"
    }
  ]
}

The only slot keys are short_term_memory_recent_topics and long_term_memory_relationship_and_others. Values are closed and bounded: summary 240 characters, at most two active_threads of 160, relationship_context 180, at most two open_questions of 120, and a valid ISO date or null for last_observed_at. chat_id is at most 240 characters; the request contains at most 1024 unique (chat_id, slot_key) pairs and at most 512 unique eligible Daily chat IDs. Each chat produces two ACK decisions, so that unique-chat limit keeps the terminal ACK within the same 1024-entry bound while allowing duplicate conversation entries for one chat. base_version=0 represents a missing slot. Both watermarks are UTC RFC3339 or explicit null; CIS never invents a timestamp while reading a backfill. Negotiated Daily requests must provide a timezone-aware metadata window_end as the non-regressing ACK evidence boundary. Every non-abstained chat carrying messages must use a nonblank, control-free, NFC-normalized chat_id of at most 240 characters; duplicate conversation entries for one chat are allowed and their latest message timestamp is aggregated for the ACK watermark. Admission rejects any eligible message later than window_end, before model execution.

canonical_digest is lowercase SHA-256 of canonical slot_value bytes. The canonicalizer normalizes strings and keys to NFC, orders object keys by UTF-16 code units, writes compact UTF-8 JSON, preserves explicit null, and renders timestamps in UTC with Z. The portable byte vector is tests/fixtures/contracts/conversation_memory_v2_golden.json.

Legacy context.memories_by_chat_id remains accepted. If legacy and v2 are both present, their canonical logical projections must be byte-equivalent or the request fails closed. off uses legacy behavior; observe validates and computes the v2 ACK without changing prompts or output; enforce projects v2 slots into the existing Daily prompt surface. Initial and InterWeek reject this side contract and remain legacy/read-only. CIS does not persist memory.

payload.context.report_feedback

Optional CP-owned context using the existing unversioned array already emitted inside payload.context. This rollout does not change FlowRunRequest, broker envelopes, topics or result contracts.

[
  {
    "feedback_id": "<cp-feedback-id>",
    "report_id": "<cp-report-id>",
    "report_type": "daily",
    "summary": "Priorizar cambios de humor.",
    "tags": ["follow_up"],
    "accepted_at": "2026-08-04T10:00:00Z",
    "source": "tutor_whatsapp",
    "status": "accepted"
  }
]
  • CIS accepts at most ten valid items, 600 normalized characters per summary
  • and 2,000 summary characters total. report_type must be initial, daily or interweek; source must be tutor_whatsapp; status must be accepted or golden_case. Up to eight unique tags of 48 characters each are retained.

  • Controls/newlines are collapsed, values are bounded and unknown fields are
  • dropped. Technical feedback/report IDs, timestamps, source and internal status are not copied into the model-facing section.

  • With REPORT_FEEDBACK_CONTEXT_RENDERER_ENABLED=true, valid items render once
  • as # Feedback previo del tutor in initial, daily and InterWeek prompts. Invalid/empty input renders nothing and never fails the flow. The raw key is removed from generic context whenever the flag is enabled, even if every item is invalid.

  • With the consumer flag false, CIS preserves the N-1 behavior: the existing
  • array remains part of generic context. Deploy consumer-first, then enable the flag before CP enables lifecycle-based context emission.

  • This is untrusted, non-evidential soft context. It may orient focus or
  • explanation only when independent evidence supports the reading. It cannot change risk, severity, confidence, taxonomy, alerts or memories.

  • Inclusion in a request means only that the context was sent to CIS. CIS does
  • not claim that the model obeyed or reflected it, and v1 adds no result receipt or per-feedback acknowledgement.

content-intelligence.flow-runs.v1

Kafka input envelope for async runs. The envelope contains metadata such as request_id, tenant_id and trace_id; it must contain exactly one of payload or payload_ref. Inline payload is exactly the same FlowRunRequest accepted by POST /analyze. payload_ref points at a compressed JSON artifact containing that request. The broker shape is documented in Broker Envelope.

content-intelligence.flow-runs.v4

V4 is the strict privacy-negotiated broker contract. It keeps the v2 transport and tenant scoping rules, forbids the v3 intent fingerprint, and requires settings.privacy in the hydrated FlowRunRequest:

{
  "contract_version": "wardian-cis-privacy-request.v1",
  "requested_mode": "protected",
  "capabilities_digest": "sha256:<digest-of-capabilities-v2>",
  "policy_version": "wardian-cis-privacy-policy.v1",
  "input_protection_mode": "masked",
  "mismatch_behavior": "reject"
}

CIS owns settings.privacy_decision; callers must not provide it. A missing request, stale digest, policy mismatch, insufficient effective assurance, or unmasked protected input produces a non-retryable v4 rejection before workflow or model execution. settings.privacy_mode remains a legacy request hint only and must not be interpreted as proof that protected handling was effective. The producer does not send an intent fingerprint in v4; CIS binds retries to a private internal digest of the normalized request and rejects a reused tenant/request ID whose logical payload changed.

content-intelligence.flow-runs.v5

V5 composes the v4 privacy request with the producer-owned v3 intent fingerprint and adds an outer content_trust_policy. The exact same wardian.content-trust-policy.v1 object is required in settings.content_trust_policy, so the policy is covered by the signed logical intent. Inline and hydrated requests reject a missing or mismatched binding before Temporal lookup. V1-v4 callers must omit both content-trust policy locations.

content-intelligence.flow-results.v1

Kafka output envelope for completed or failed async runs. The successful envelope carries the same FlowRunResult that GET /runs/{run_id} returns. The result envelope is documented in _docs/OUTPUT_CONTRACTS.md.

FlowRunRequest

HTTP entrypoint:

POST /analyze
Authorization: Bearer <token>
Content-Type: application/json

Request shape:

{
  "flow": "initial-ingestion-report",
  "recipe": "initial-ingestion-report-text-only",
  "settings": {
    "privacy_mode": "debug_plaintext",
    "media_policy": {},
    "output_language": "es",
    "video_processing": {
      "backend": "gemini",
      "media_resolution": "MEDIA_RESOLUTION_LOW",
      "fps": 1
    }
  },
  "payload": {}
}

Fields:

FieldRequiredNotes
flowyesOne of smoke-test, initial-ingestion-report, daily-summary-and-notifications-report, interweek-report.
recipenoMust belong to the selected flow. Defaults from services/flows.py.
settings.privacy_modenodebug_plaintext or protected.
settings.privacyno over HTTP/legacy broker contracts; required by flow-run v4Strict privacy request frozen by CP from capabilities v2.
settings.privacy_decisionno; CIS-ownedInternal normalized decision. External v4 callers must omit it.
settings.media_policynoRequest-level boolean overrides by media key. Prefer recipe defaults unless a run needs an explicit override.
settings.output_languagenoCurrently advisory for callers/prompts; examples use es.
settings.video_processing.backendnogemini (default) or legacy.
settings.video_processing.media_resolutionnoFull OpenRouter/Gemini enum: MEDIA_RESOLUTION_LOW (default), MEDIA_RESOLUTION_MEDIUM, MEDIA_RESOLUTION_HIGH, or MEDIA_RESOLUTION_UNSPECIFIED.
settings.video_processing.fpsnoPositive local conversion FPS for animated GIF/WebP/APNG; defaults to 1 and is not sent to OpenRouter.
settings.content_trust_policyonly broker v5Exact wardian.content-trust-policy.v1 object (policy_version=conversations-are-data.v1, `mode=observeenforce`). It is part of the signed logical intent and must equal the outer v5 policy.
payloadyesRaw WAHA events or canonical FlowPayload.

Recipes And Media

Recipes choose whether media is downloaded and processed. The incoming message shape should not drop media messages just because a text-only recipe is used; text-only recipes still render explicit media placeholders.

Recipe suffixAudioImage/sticker/GIFVideoDocumentLinks
text-onlynonononono
text-audioyesnononono
all-mediayesyesyesyesyes

Defaults:

FlowDefault recipe
smoke-testsmoke-test-default
initial-ingestion-reportinitial-ingestion-report-text-only
daily-summary-and-notifications-reportdaily-summary-and-notifications-report-text-only
interweek-reportinterweek-report-default

Whole-Flow Input Contract

The input contract for the whole /analyze flow is the FlowRunRequest envelope. The service accepts this envelope over HTTP, inside the Kafka FlowRunEnvelope.payload, or via a Kafka FlowRunEnvelope.payload_ref artifact. Runtime normalizes it into one resolved request:

{
  "flow": "daily-summary-and-notifications-report",
  "recipe": "daily-summary-and-notifications-report-text-only",
  "settings": {
    "privacy_mode": "debug_plaintext",
    "media_policy": {},
    "output_language": "es"
  },
  "payload": {
    "source": "core",
    "monitored": {
      "id": "minor-1",
      "name": "Felipe",
      "phone": null
    },
    "metadata": {
      "window_start": "2026-06-18T00:00:00-03:00",
      "window_end": "2026-06-19T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "context": {
      "identity_contract_version": "wardian-core-identity.v1",
      "memories_by_chat_id": {},
      "previous_daily_summaries": [],
      "previous_notifications": [],
      "previous_interweek_reports": [],
      "monitored_profile": {}
    },
    "user_stats_metadata": {
      "contract_version": "wardian-user-stats.v1",
      "top_contacts_by_messages": [
        {
          "chat_id": "5491111111111@c.us",
          "name": "Sofi Colegio",
          "total_messages": 214,
          "monitored_messages": 81,
          "monitored_participation_ratio": 0.38
        }
      ]
    },
    "conversations": [
      {
        "chat": {
          "id": "chat-1",
          "name": "Sofi",
          "type": "direct"
        },
        "metadata": {
          "direct_identity": {
            "canonical_phone_jid": "5491111111111@c.us",
            "saved_contact_name": "Sofi Colegio"
          },
          "shared_groups_with_contact": [
            {
              "id": "group-1",
              "subject": "1er ano",
              "description": "Grupo escolar"
            }
          ]
        },
        "messages": [
          {
            "id": "msg-1",
            "timestamp": "2026-06-18T18:05:00-03:00",
            "sender": {
              "role": "contact",
              "id": "5491111111111@c.us",
              "name": "Sofi",
              "is_saved": true,
              "is_new": false
            },
            "type": "text",
            "text": "Hoy estuvo tranquilo.",
            "metadata": {
              "reply_to": null
            }
          }
        ]
      }
    ]
  }
}

Envelope fields:

FieldMeaning
flowRequired flow id. It selects the product job family.
recipeOptional recipe id. If omitted, runtime resolves the selected flow's default recipe.
settingsOptional per-run settings. Missing settings default to an empty FlowRunSettings.
settings.privacy_modedebug_plaintext or protected; controls debug/plaintext handling where implemented.
settings.media_policyOptional media policy overrides by key. Recipes remain the preferred source of media behavior.
settings.output_languageAdvisory output language, currently examples use es.
settings.video_processingOptional backend, Gemini media_resolution, and local animated-media fps overrides.
settings.content_trust_policyBroker v5 policy bound inside the signed request. Omitted for v1-v4. Ordinary direct API and dev-tool calls reject this transport-owned field instead of allowing it to override local policy.
payloadRequired data for the flow. It can be canonical FlowPayload or raw WAHA data.

Canonical payload fields:

FieldMeaning
sourceProducer/format label: for example core, broker, playground or waha.
monitoredThe monitored child/person. Defaults to {"id": "monitored", "name": "MONITOREADO"} if omitted in canonical payloads.
metadataRun/window metadata. Use this for dates, period bounds, timezone and trace/debug context; do not use it as product memory.
contextExplicit Core-owned context for the run: memories, previous summaries, previous notifications, previous InterWeek reports and monitored profile.
user_stats_metadataOptional Core-computed usage stats. Use strict wardian-user-stats.v2; exact v1 remains accepted during rollout. CIS does not derive or backfill missing metrics.
conversationsCanonical conversations to analyze. Required except for interweek-report and authoritative-zero daily payloads.
adapter_summaryNormalization summary, usually produced by the WAHA adapter or chat-name unification.

Recommended metadata keys:

KeyUsed byMeaning
window_startConversation-based flows.Inclusive start of the analyzed message window.
window_endConversation-based flows.Exclusive or producer-defined end of the analyzed message window.
timezoneAll flows.Timezone used to interpret message day boundaries and conversation-builder time labels. If missing or invalid, CIS defaults message timestamps to America/Argentina/Buenos_Aires.
period_startinterweek-report.First day covered by an InterWeek synthesis.
period_endinterweek-report.Last day covered by an InterWeek synthesis.

Recommended context keys:

KeyUsed byMeaning
identity_contract_versionAll report flows.Version marker for Core-provided identity/context shape.
memories_by_chat_idchat_id_summarization, daily and interweek.Core-owned memory state keyed by chat id. CIS reads it but does not persist it. Conversation-backed prompts receive matching memories inside each ConversationDocument; interweek receives them in explicit context because it has no conversation documents.
previous_daily_summariesDaily and interweek.Prior daily summaries for continuity, trend breaks and repetition avoidance.
previous_notificationsDaily and interweek.Prior notifications so daily does not repeat stale alerts and interweek can synthesize trajectory. Core stamps each item with date (YYYY-MM-DD, copied from the source report's daily_summary.date) so the prompt renders it under the matching previous day; items without a string date fall back to a "(sin fecha asociada)" section.
previous_interweek_reportsDaily and interweek.Prior InterWeek reports for comparison and continuity.
monitored_profileDaily and interweek.Core-owned stable profile/context for the monitored child.
tutor_report_guidanceInitial, daily and interweek reports.Optional wardian-tutor-report-guidance.v1 soft preference. CIS validates and renders it in a dedicated section; it never enters chat summarization or generic context.
monitored_age_bandInitial, daily and interweek reports.Optional wardian-monitored-age-band.v1 soft personalization context. Known bands render only behind the consumer flag; unknown/invalid input never reaches prompts or generic context.
report_feedbackInitial, daily and interweek reports.Existing CP-owned array of accepted/golden report feedback. The dedicated consumer renderer validates and bounds it behind REPORT_FEEDBACK_CONTEXT_RENDERER_ENABLED; it never enters chat summarization.
known_entitiesEntity extraction for initial and daily.Optional CP-owned wardian-entity-catalog.v1 projection of pending/validated people and pets. It is bounded and rendered only to the dedicated extractor; it never enters report, notification, InterWeek or chat-memory prompts.

Runtime Normalization And Prompt Inputs

The raw request is not sent directly to AI prompts. Runtime first resolves flow/recipe, normalizes raw WAHA when needed, optionally runs media processors, and renders ConversationDocument[].

StageInputOutput
resolve_flow_recipeflow, optional recipe.Resolved flow id and recipe id.
adapter_normalizationRaw WAHA data, or already-canonical FlowPayload.Canonical FlowPayload with message timestamps normalized to the effective timezone.
chat_name_unificationWAHA-origin conversations with possible aliases.Canonical conversations with aliases collapsed and selected display names.
temporal_mediaMedia messages allowed by recipe policy.V2 media_processed indexed internally by (chat_id, message_id) and emitted as explicit identity entries; unambiguous legacy input remains a message-id map during drain.
conversation_builderFlowPayload plus media_processed.Rendered ConversationDocument[] consumed by prompts, with date headers, [HH:MM], and hourly stats based on the effective timezone.
Flow AI stepsPrompt-specific slices of FlowPayload and rendered documents.Strict JSON model outputs.

Prompt user-input surfaces:

Flow/stepRuntime data sent to the prompt user message
smoke_testRendered conversation documents only.
initial_reportpayload.monitored, generic context without top-level memories/guidance/age band, optional normalized tutor guidance and known age band, dedicated # Estadísticas por chat (Core) section without technical source_coverage, rendered conversation documents.
chat_id_summarizationMonitored child minimal identity (name) and rendered conversation documents for the current internal batch. It receives no generic context, tutor guidance or age band.
entity_extractionInitial/daily only, behind ENTITY_EXTRACTION_ENABLED: minimal monitored identity, validated known-entity catalog, and deterministic bounded conversation/message batches with a same-chat message-id evidence index.
daily_summary_and_notificationsMarkdown sections for monitored child, run metadata, stats without technical source_coverage, optional normalized tutor guidance and known age band, previous reports/notifications, leftover context without memories/guidance/age band, and conversation documents with matching per-chat memories.
interweek_reportMarkdown sections for monitored child, run metadata, stats without technical source_coverage, optional normalized tutor guidance and known age band, and previous reports/notifications. It receives no conversation documents or per-chat memories in v1.

Flow-specific input requirements:

FlowRequired minimum inputImportant optional input
smoke-testAt least one canonical conversation or raw WAHA event that normalizes to one conversation.None; this is a runtime health check.
initial-ingestion-reportHistorical/snapshot conversations for the initial report window.user_stats_metadata, identity metadata, group/shared-group context, context.tutor_report_guidance and context.monitored_age_band.
daily-summary-and-notifications-reportDaily conversations/messages, or conversations: [] plus V1 legacy zero or valid V2 verified_zero/unverified_zero.Supported-zero payloads require valid half-open window_start, window_end and timezone; V2 also requires matching source coverage.
interweek-reportExplicit context for the period, or supported V1/V2 zero stats.Supported-zero payloads require a valid canonical half-open window and timezone; V2 also requires matching source coverage, and conversations conflicting with zero fail closed.

Canonical FlowPayload

Use this shape when Core or another module has already normalized WhatsApp content into conversations.

{
  "source": "core",
  "monitored": {
    "id": "minor-1",
    "name": "Felipe",
    "phone": null
  },
  "user_stats_metadata": {
    "contract_version": "wardian-user-stats.v1",
    "window": {
      "start": "2026-06-18T00:00:00-03:00",
      "end": "2026-06-19T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "generated_at": "2026-06-19T00:05:00-03:00",
    "top_groups_by_messages": [
      {
        "chat_id": "120363000000000000@g.us",
        "name": "1er ano",
        "total_messages": 486,
        "monitored_messages": 73,
        "monitored_participation_ratio": 0.15
      }
    ],
    "top_contacts_by_messages": [
      {
        "chat_id": "5491111111111@c.us",
        "name": "Sofi Colegio",
        "total_messages": 214,
        "monitored_messages": 81,
        "monitored_participation_ratio": 0.38
      }
    ],
    "top_view_once_chats_by_messages": [
      {
        "chat_id": "120363000000000000@g.us",
        "name": "1er ano",
        "chat_type": "group",
        "total_view_once_messages": 28,
        "monitored_view_once_messages": 5
      }
    ],
    "night_messages": {
      "hour_range_local": "22:00-06:00",
      "total_messages": 92,
      "monitored_messages": 31,
      "top_chats": [
        {
          "chat_id": "5492222222222@c.us",
          "name": "Tomi",
          "chat_type": "direct",
          "total_messages": 34,
          "monitored_messages": 14
        }
      ]
    },
    "message_type_breakdown": {
      "text": 820,
      "audio": 36,
      "image": 91,
      "video": 12,
      "view_once": 31
    },
    "new_or_rare_contacts": [
      {
        "chat_id": "5493333333333@c.us",
        "name": "Contacto nuevo",
        "total_messages": 18,
        "monitored_messages": 6,
        "first_observed_at": "2026-06-18T21:10:00-03:00"
      }
    ]
  },
  "metadata": {
    "window_start": "2026-06-01T00:00:00-03:00",
    "window_end": "2026-06-16T00:00:00-03:00",
    "timezone": "America/Argentina/Buenos_Aires"
  },
  "context": {
    "identity_contract_version": "wardian-core-identity.v1",
    "memories_by_chat_id": {
      "120363000000000000@g.us": {
        "short_term_memory_recent_topics": "texto/json libre persistido por Core",
        "long_term_memory_relationship_and_others": "texto/json libre persistido por Core"
      }
    },
    "previous_daily_summaries": [],
    "previous_notifications": [],
    "previous_interweek_reports": [],
    "monitored_profile": {}
  },
  "conversations": [
    {
      "chat": {
        "id": "120363000000000000@g.us",
        "name": "1er ano",
        "type": "group",
        "description": "Grupo escolar",
        "participant_count": 24,
        "monitored_is_admin": false
      },
      "group": {
        "id": "120363000000000000@g.us",
        "subject": "1er ano",
        "description": "Grupo escolar"
      },
      "metadata": {
        "source_chat_type": "group",
        "group_identity": {
          "group_jid": "120363000000000000@g.us",
          "group_name": "1er ano",
          "title_source": "waha_group"
        },
        "group_participants": [
          {
            "lid": "123456789@lid",
            "phone_jid": "5491111111111@s.whatsapp.net",
            "canonical_phone_jid": "5491111111111@c.us",
            "is_saved": true,
            "saved_contact_name": "Sofi Colegio",
            "contact_labels": ["Colegio"]
          }
        ]
      },
      "messages": [
        {
          "id": "msg-1",
          "timestamp": "2026-06-15T18:05:00-03:00",
          "sender": {
            "role": "contact",
            "id": "5491111111111@c.us",
            "name": "Sofi",
            "is_saved": true,
            "is_new": false
          },
          "type": "text",
          "text": "Hola Felipe",
          "metadata": {
            "reply_to": null
          }
        }
      ]
    },
    {
      "chat": {
        "id": "5491111111111@c.us",
        "name": "Sofi Colegio",
        "type": "direct"
      },
      "metadata": {
        "shared_groups_with_contact": [
          {
            "id": "120363000000000000@g.us",
            "subject": "1er ano",
            "description": "Grupo escolar"
          }
        ]
      },
      "messages": [
        {
          "id": "msg-2",
          "timestamp": "2026-06-15T19:05:00-03:00",
          "sender": {"role": "contact", "id": "5491111111111@c.us", "name": "Sofi"},
          "type": "text",
          "text": "Nos vemos en el grupo"
        }
      ]
    }
  ]
}

Top-level fields:

FieldRequiredNotes
sourceyesProducer label, for example core, broker, playground or waha.
monitorednoDefaults to {"id": "monitored", "name": "MONITOREADO"} if omitted.
conversationsyes except interweek-report and supported zero dailyAt least one conversation for conversation-based flows. Daily accepts [] with exact V1 root total_messages: 0 or valid V2 totals.activity_events: 0 plus source coverage; InterWeek can run from explicit context/stats only.
user_stats_metadatanoCore-computed usage stats. V2 is the strict exhaustive contract and v1 remains compatible during rollout. CIS also temporarily accepts the previously shipped coverage-aware V2 root shape so CIS can deploy before the new CP producer. Technical source_coverage is never rendered into model prompts. Stats never enter chat_id_summarization or constitute alert/memory evidence.
contextnoExplicit Core-owned context: identity contract version, memories by chat_id, previous summaries, previous notifications, previous InterWeek reports and monitored profile.
metadatanoRun/window/debug metadata. Do not use it as implicit app state.
adapter_summarynoUsually populated by the WAHA adapter, not by Core.

Required wardian-user-stats.v2 root keys:

KeyMeaning
contract_versionExact producer marker wardian-user-stats.v2. Missing stats and exact V1 remain valid during rollout. Any explicit contract_version other than exact V1/V2 fails validation.
windowCanonical half-open stats window with start, end and timezone; it must agree with repeated payload metadata. window_end is exclusive. For InterWeek metadata, period_start is the local date of window.start and period_end is the local date containing the instant immediately before window.end.
totalsGlobal messages and interaction_events counters, plus activity_events and active_chats. A Counter is {total, sent_by_monitored, received_by_monitored}.
rollupsGlobal text, voice, multimedia and other counters. multimedia is exactly image plus video.
by_typeComplete message taxonomy: text, voice, audio_file, audio_unknown, image, video, document, sticker, gif, contact_card, location, poll and unknown.
view_onceTransversal messages counter, sparse by_type counters and detection_unknown_messages. Confirmed View Once still counts once in its base message type.
interactions_by_typeInteraction counters, currently reaction. Reactions are not messages.
coverageGlobal quality counters for unknown type/audio subtype and retained media payload coverage.
chatsEvery active direct/group chat in the interval, ordered by messages.total DESC, chat_id ASC, with no top-N truncation. Each item repeats the canonical counters and adds identity/activity fields.
source_coverageOptional closed wardian-source-coverage.v1 or wardian-source-coverage.v2 technical attestation. It is required when Daily/InterWeek V2 claims totals.activity_events: 0, may be omitted by Initial, and is never sent to a model. V2 adds intentional planned-pause exclusions without changing the flow envelope.

During the rollout, the same V2 marker has two explicitly validated shapes. A payload containing any exhaustive-only field (totals, rollups, by_type, view_once, interactions_by_type, coverage or chats) must satisfy the strict exhaustive schema above and never falls back to the older parser. A V2 payload without those fields is treated as the previously published coverage-aware shape and must provide root window_start, window_end, timezone, non-negative integer total_messages and closed source_coverage. Its root and coverage windows must reconcile exactly; additional legacy aggregate fields remain compatible. Legacy V2 uses root total_messages for the temporary Daily/InterWeek zero decision, does not participate in exhaustive chat-ID reconciliation and still keeps source_coverage outside every model prompt. Executable frozen examples are cis_user_stats_v2_legacy_verified_zero.json and cis_user_stats_v2_legacy_unverified_zero.json.

The v2 validator reconciles every direction, type, rollup, interaction, View Once and per-chat total before a prompt is built. It also rejects duplicate chat IDs, incorrect order and group-only fields on direct chats. Within each chat, monitored_participation_ratio is messages.sent_by_monitored / messages.total (or 0 when no messages). The temporary v1 projection monitored_participation_pct is the integer floor of messages.sent_by_monitored * 100 / messages.total (or 0 when no messages); it is derived from counters rather than floating-point ratio math.

Stats are trusted only as Core-computed counts. Chat names and IDs remain untrusted display labels, and stats cannot provide conversation evidence, trigger alerts, influence daily memory updates or enter chat_id_summarization. The report output may summarize totals and at most five relevant chats; the prompt section still receives every chat. Exact v2 requires every direct/group payload.conversations[].chat.id to exist in user_stats_metadata.chats. The reverse is intentionally not required: the exhaustive stats may include an active chat whose retained rows do not produce a renderable conversation document. Status and channel conversations are outside this cross-check. Exact v2 totals.activity_events: 0 activates deterministic no-activity handling only with valid source coverage; the v1 root total_messages as JSON integer 0 remains the rollout fallback. A zero claim that contradicts conversations or InterWeek evidence fails permanently as user_stats_invalid.

See tests/fixtures/contracts/payload_user_stats_v1_compat.json for the legacy v1 shape.

wardian-user-stats.v2 source coverage

V2 leaves the public flow-run envelope and Daily/InterWeek payload shape intact, and may add one technical attestation under user_stats_metadata.source_coverage:

{
  "contract_version": "wardian-user-stats.v2",
  "window": {
    "start": "2026-07-11T00:00:00-03:00",
    "end": "2026-07-12T00:00:00-03:00",
    "timezone": "America/Argentina/Buenos_Aires"
  },
  "totals": {
    "messages": {"total": 0, "sent_by_monitored": 0, "received_by_monitored": 0},
    "interaction_events": {"total": 0, "sent_by_monitored": 0, "received_by_monitored": 0},
    "activity_events": 0,
    "active_chats": 0
  },
  "rollups": "... zero counters ...",
  "by_type": "... zero counters ...",
  "view_once": "... zero counters ...",
  "interactions_by_type": "... zero counters ...",
  "coverage": "... zero quality counters ...",
  "chats": [],
  "source_coverage": {
    "contract_version": "wardian-source-coverage.v1",
    "status": "verified",
    "reason_codes": [],
    "window": {
      "start": "2026-07-11T00:00:00-03:00",
      "end": "2026-07-12T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "verified_at": "2026-07-12T00:00:05-03:00"
  }
}

Executable cross-repo fixtures are tests/fixtures/contracts/cis_user_stats_v1_zero.json, cis_user_stats_v2_verified_zero.json and cis_user_stats_v2_unverified_zero.json.

wardian-source-coverage.v1 remains accepted unchanged. The V2 side-contract keeps the same field location and adds planned-pause metadata only:

{
  "contract_version": "wardian-source-coverage.v2",
  "status": "intentionally_partial",
  "reason_codes": ["planned_pause"],
  "window": {
    "start": "2026-07-11T00:00:00-03:00",
    "end": "2026-07-12T00:00:00-03:00",
    "timezone": "America/Argentina/Buenos_Aires"
  },
  "verified_at": "2026-07-12T00:00:05-03:00",
  "excluded_intervals": [
    {
      "start": "2026-07-11T12:00:00-03:00",
      "end": "2026-07-11T18:00:00-03:00",
      "reason": "planned_pause"
    }
  ]
}

V2 statuses are exactly verified, unverified, intentionally_excluded and intentionally_partial; it adds planned_pause to the V1 reason allowlist. Every excluded interval is a closed object containing only start, end and reason: planned_pause. Intervals are ordered, non-overlapping, inside the half-open coverage window and use timezone-aware RFC3339 values.

  • verified has no reasons or intervals and requires verified_at >= window.end.
  • intentionally_excluded has exactly reason_codes: ["planned_pause"], fully
  • covers the window, requires zero activity and has a verified timestamp.

  • intentionally_partial has the same sole reason, excludes less than the full
  • window and has a verified timestamp.

  • Any real operational reason combined with planned_pause must use
  • unverified, keep verified_at: null and preserve both reasons. Intentional coverage never hides a real gap.

  • A conversation message inside an excluded interval is a permanent
  • user_stats_invalid error, before Temporal/media/model execution. CIS does not silently filter contradictory producer payloads.

  • For InterWeek, previous_daily_summaries and previous_notifications are
  • filtered by their canonical local date: items from a calendar day fully covered by the V2 exclusions never reach a prompt. While exclusions exist, undated or malformed-date period evidence is also removed because CIS cannot prove that it belongs to an allowed interval. Evidence from a day excluded only in part remains available.

Frozen CP-parity fixtures are cis_user_stats_v2_planned_pause_full.json, cis_user_stats_v2_planned_pause_partial.json and cis_user_stats_v2_planned_pause_mixed_gap.json.

For wardian-source-coverage.v1, source_coverage and its window are closed objects: unknown, missing or mistyped fields are contract errors. Its status is exactly verified or unverified, and its closed reason-code set is:

  • coverage_warmup
  • session_missing
  • session_not_connected
  • session_degraded
  • session_gap
  • live_check_failed
  • bootstrap_pending
  • bootstrap_incomplete
  • ingestion_backlog
  • coverage_signal_unknown

For V1 verified, reason_codes must be empty and verified_at must be a timezone-aware RFC3339 timestamp at or after exclusive window_end. For V1 unverified, at least one unique allowed reason is required and verified_at must be JSON null. Root stats, payload metadata and coverage windows must use the same IANA timezone, represent the same start/end UTC instants and have end > start. A wardian-user-stats.v1 payload carrying source_coverage, any explicit unsupported contract_version, a malformed attestation, or a Daily/InterWeek V2 zero without one fails during request validation, before Temporal or media fan-out.

For monitored_participation_ratio, Core should send monitored_messages / total_messages as a decimal number. Stats are context, not evidence by themselves: daily notifications require conversation evidence or concrete antecedents, while InterWeek may use stats for period-level trajectory only when they are present. source_coverage is never prompt/model evidence. The deterministic zero decision is none | verified_zero | unverified_zero | planned_pause_full | planned_pause_partial: V1 exact zero remains temporary legacy verified_zero; V2 with exact totals.activity_events: 0 derives the decision from coverage status. Every deterministic zero decision skips models and media. A payload that claims zero while also carrying conversations, InterWeek period evidence, non-zero sibling counters/breakdowns, non-empty top-activity collections or a different stats window fails closed during request normalization.

V2 with totals.activity_events > 0 follows the existing activity path after contract validation, with source_coverage removed from prompts. Initial ingestion does not use either deterministic-zero decision; a V2 initial payload, if supplied, is validated but remains model-driven and still requires conversations.

Chat fields:

FieldRequiredNotes
chat.idyesStable upstream chat id.
chat.namenoDisplay name when known.
chat.typenodirect, group, status, channel or unknown. If omitted, known group JIDs infer group; otherwise direct.
chat.descriptionnoOptional group/channel description.
chat.participant_countnoAdvisory only.
chat.monitored_is_adminnoUseful for group context if known.
grouprequired for group chats when Core knows itGroup information rendered into prompts. Preferred shape is { "id": "...", "subject": "...", "description": "..." }.
metadatanoConversation-scoped metadata.

Group fields:

FieldRequiredNotes
group.idyes for groupStable group ID/JID. Usually the same value as chat.id for WhatsApp groups.
group.subjectyes when knownHuman-readable group name. CIS renders this as subject in prompts.
group.descriptionnoGroup description/about text when Core or WAHA knows it. CIS renders this as description; absent descriptions render as no disponible.

Core identity metadata

Canonical Core identity fields live in conversation.metadata. These objects are optional at schema-validation time because metadata accepts a dictionary, but when payload.context.identity_contract_version is wardian-core-identity.v1, producers should use the fields below and should not require consumers to infer undocumented nested shapes.

General rules:

  • Keep technical chat/JID alias data in direct_identity.
  • Keep saved-contact and human contact data in contact_identity.
  • Keep group-level identity in group_identity.
  • Keep group participant identity in group_participants[].
  • Keep direct-chat shared group context in shared_groups_with_contact[].
  • Omit unknown values rather than sending empty strings. Extra keys may pass
  • validation, but they are not part of the public contract until documented here.

Direct chat identity

conversation.metadata.direct_identity identifies the 1:1 WhatsApp chat and the technical identifiers that may point to the same contact.

FieldTypeStatusMeaning
raw_chat_idstringrecommended when knownOriginal upstream chat id before normalization. Often a LID such as 82889177862172@lid.
lidstringrecommended when knownWhatsApp LID alias for the direct contact or chat.
phone_jidstringrecommended when knownWhatsApp phone JID, usually ...@s.whatsapp.net.
canonical_phone_jidstringpreferred stable id when knownNormalized phone JID used by CIS for stable matching, usually ...@c.us.
push_namestringoptionalSender-provided display name. Use only as secondary identity; do not overwrite saved-contact names with it.
shared_groupsarrayfallback onlyAccepted legacy/fallback location for direct shared groups. Prefer conversation.metadata.shared_groups_with_contact.

Example:

{
  "direct_identity": {
    "raw_chat_id": "82889177862172@lid",
    "lid": "82889177862172@lid",
    "phone_jid": "5491124973119@s.whatsapp.net",
    "canonical_phone_jid": "5491124973119@c.us",
    "push_name": "sofi"
  }
}

Contact identity

conversation.metadata.contact_identity is the Core contact object or contact projection for the same direct chat. It is where human-readable saved contact data belongs.

FieldTypeStatusMeaning
contact_idstringoptionalStable Core contact id when Core has one.
is_savedbooleanoptionalWhether the contact is saved in the monitored account/contact book.
saved_contact_namestringpreferred display name when knownSaved contact name. This has higher priority than push_name.
display_namestringoptionalCore-computed display name if distinct from saved name.
namestringoptionalGeneric contact name field when Core exposes one.
full_namestringoptionalFull contact name when available.
contact_labelsarray of stringsoptionalCore labels such as school, family, club or other product tags.
shared_groupsarrayfallback onlyAccepted legacy/fallback location for direct shared groups. Prefer conversation.metadata.shared_groups_with_contact.

Example:

{
  "contact_identity": {
    "contact_id": "core-contact-1",
    "is_saved": true,
    "saved_contact_name": "Sofi Colegio",
    "contact_labels": ["Colegio"]
  }
}

Group identity

conversation.metadata.group_identity identifies a WhatsApp group. For prompt rendering, explicit conversation.group fields have priority, then group_identity, then chat.

FieldTypeStatusMeaning
group_jidstringpreferred stable id when knownWhatsApp group JID, usually ...@g.us.
idstringfallback idAlternate group id accepted by the renderer.
raw_chat_idstringoptionalOriginal upstream group chat id before normalization.
subjectstringpreferred title aliasHuman-readable group subject.
group_namestringpreferred when subject is absentGroup name from Core or WAHA.
titlestringfallback titleAlternate title accepted by the renderer.
display_namestringfallback titleAlternate display name accepted by the renderer.
descriptionstringoptionalGroup description/about text.
title_sourcestringoptionalProvenance for the selected title, for example waha_group or core_group.

Example:

{
  "group_identity": {
    "group_jid": "120363000000000000@g.us",
    "group_name": "1er ano",
    "title_source": "waha_group"
  }
}

Group participants

conversation.metadata.group_participants[] maps inbound group senders to stable ids and human names. CIS uses these entries to replace placeholder names such as Participant in transcripts. For matching to work, each participant should include at least one alias field that can match message sender metadata.

FieldTypeStatusMeaning
lidstringrecommended when knownWhatsApp LID alias for the participant.
jidstringoptional aliasGeneric participant JID. Used as a matching alias.
idstringoptional aliasGeneric participant id. Used as a matching alias.
canonical_phone_jidstringpreferred stable phone id when knownNormalized phone JID, usually ...@c.us.
phone_jidstringrecommended when knownWhatsApp phone JID, usually ...@s.whatsapp.net.
raw_chat_idstringoptional aliasOriginal upstream participant/chat id if present.
participant_jidstringoptional aliasParticipant JID copied from upstream message metadata.
aliasesarray of stringsoptional aliasesAdditional ids that should match this participant.
sender_aliasesarray of stringsoptional aliasesAdditional sender ids that should match this participant.
saved_contact_namestringpreferred display name when knownSaved contact name. Highest-priority human label.
display_namestringoptional display nameCore-computed display name.
namestringoptional display nameGeneric participant/contact name.
full_namestringoptional display nameFull participant/contact name.
push_namestringsecondary display nameSender-provided name. Lower priority than saved/contact names.
is_savedbooleanoptionalWhether the participant is saved as a contact.
contact_labelsarray of stringsoptionalCore labels for this participant.
message_countnumberoptionalAdvisory count from dump/Core indexing. Not evidence by itself.

Example:

{
  "group_participants": [
    {
      "lid": "123456789@lid",
      "phone_jid": "5491111111111@s.whatsapp.net",
      "canonical_phone_jid": "5491111111111@c.us",
      "is_saved": true,
      "saved_contact_name": "Sofi Colegio",
      "contact_labels": ["Colegio"]
    }
  ]
}

Direct shared groups

For direct 1:1 chats, Core should send groups shared between the monitored minor and the contact in conversation.metadata.shared_groups_with_contact. CIS renders these into prompts under Grupos compartidos con este contacto. Use shared_groups_with_contact as the canonical metadata key for this list.

Canonical item fields:

FieldTypeStatusMeaning
idstringpreferredStable shared group id/JID.
subjectstringpreferredHuman-readable group subject.
descriptionstringoptionalGroup description/about text.

Accepted fallback aliases for existing producers: group_id or group_jid for id; name, group_name or title for subject; group_description for description.

{
  "shared_groups_with_contact": [
    {
      "id": "120363000000000000@g.us",
      "subject": "1er ano",
      "description": "Grupo escolar"
    }
  ]
}

Message identity bridge

Group participant matching uses message-level identity hints. Producers should put these in message.metadata when available:

FieldTypeStatusMeaning
canonical_sender_idstringrecommended when knownNormalized sender id chosen by Core/adapter.
original_sender_idstringoptionalOriginal upstream sender id before normalization.
participant_jidstringoptionalUpstream group participant JID.
lidstringoptionalSender LID alias.
canonical_phone_jidstringoptionalSender normalized phone JID.
phone_jidstringoptionalSender phone JID.
sender_aliasesarray of stringsrecommended for group aliasesAll known sender aliases for matching against group_participants[].

CIS matches messages to group_participants[] using sender.id, canonical_sender_id, original_sender_id, participant_jid, lid, canonical_phone_jid, phone_jid and sender_aliases[]. Display-name priority for matched identities is saved_contact_name, then display_name, name, full_name, then push_name.

Message fields:

For the negotiated v2 media contract, FlowPayload includes media_identity_contract_version: "wardian.media-identity.v2". The identity of an analyzable media/link message is the hierarchical pair conversation.chat.id + message.id; producers do not concatenate or hash the pair into an artificial key. The same pair may appear with the same message.id in different chats. Repeating the complete pair is invalid. Without the v2 marker, cross-chat reuse of a processable media message.id fails closed as legacy_media_identity_ambiguous before Temporal starts.

FieldRequiredNotes
idyesUnique inside the conversation.
timestampyesISO 8601 string preferred. Producers should not rely on numeric epoch parsing for canonical payloads.
sender.roleyesmonitored or contact.
sender.idnoUpstream sender id/JID when available.
sender.namenoDisplay name when available.
sender.is_savednoWhether the contact is saved.
sender.is_newnoWhether this is a first/new interaction in the window.
typenoDefaults to text. Supported values are listed below.
textconditionalRequired unless the message has media_url or is a supported structured event type.
media_urlnoRemote http/https URL reachable by CIS workers, or the internal wardian://media-omitted-by-recipe placeholder. Embedded data URIs and local paths are invalid; do not embed media bytes.
mime_typenoMIME type for media routing.
duration_secondsnoUseful for audio/video.
metadatanoEvent flags, quote ids, media flags and producer metadata.

Supported canonical message types:

text, audio, image, sticker, gif, video, document, location, contact_card, poll,
reaction, edit, revoke, call, group_event, order, product, interactive, event,
unknown

Recommended message.metadata keys:

KeyMeaning
event_nameUpstream event name when known.
reply_toMessage id quoted/replied to inside the same chunk.
target_message_idTarget of a reaction, edit, revoke, poll or event response.
is_statusMessage came from WhatsApp status.
is_channelMessage came from a newsletter/channel JID.
is_view_onceWAHA marked the media/message as view once. This is context, not risk evidence by itself.
is_ephemeralWAHA marked the message as ephemeral. This is context, not risk evidence by itself.
media_unavailableMedia exists but no usable URL is available.
media_errorUpstream media error, for example media_expired.
media_filenameUpstream filename when available.

Extra fields are allowed on monitored, chat, sender, message and payload, but prompts should not depend on undocumented extras until this file is updated.

Instantiated Requests By Flow

The JSON files in tests/fixtures/contracts/ are the executable examples for this section. Tests validate every payload fixture with FlowRunRequest.model_validate, execute it through run_recipe with a deterministic model, and compare the returned FlowRunResult.output with the matching output fixture.

smoke-test

Use a minimal valid conversation fixture. This flow verifies the runtime/model JSON path and should not be used for product state.

Executable fixture: tests/fixtures/contracts/payload_smoke_test.json.

{
  "flow": "smoke-test",
  "recipe": "smoke-test-default",
  "settings": {"output_language": "es"},
  "payload": {
    "source": "core",
    "monitored": {"id": "minor-1", "name": "Felipe"},
    "metadata": {
      "window_start": "2026-06-18T00:00:00-03:00",
      "window_end": "2026-06-19T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "context": {},
    "conversations": [
      {
        "chat": {"id": "chat-1", "name": "Sofi", "type": "direct"},
        "messages": [
          {
            "id": "msg-1",
            "timestamp": "2026-06-18T18:05:00-03:00",
            "sender": {"role": "contact", "name": "Sofi"},
            "type": "text",
            "text": "Hola"
          }
        ]
      }
    ]
  }
}

initial-ingestion-report

Use a historical import or configured initial snapshot. Send every conversation that belongs in the initial report window. user_stats_metadata is optional but is the only source the prompt should use for usage statistics.

Executable fixture: tests/fixtures/contracts/payload_initial_ingestion_report.json.

{
  "flow": "initial-ingestion-report",
  "recipe": "initial-ingestion-report-text-only",
  "settings": {"privacy_mode": "debug_plaintext", "output_language": "es"},
  "payload": {
    "source": "core",
    "monitored": {"id": "minor-1", "name": "Felipe", "phone": null},
    "user_stats_metadata": {
      "contract_version": "wardian-user-stats.v1",
      "top_groups_by_messages": [
        {
          "chat_id": "group-1",
          "name": "1er ano",
          "total_messages": 486,
          "monitored_messages": 73,
          "monitored_participation_ratio": 0.15
        }
      ],
      "night_messages": {
        "hour_range_local": "22:00-06:00",
        "total_messages": 92,
        "monitored_messages": 31
      }
    },
    "metadata": {
      "window_start": "2026-06-01T00:00:00-03:00",
      "window_end": "2026-06-18T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "context": {
      "identity_contract_version": "wardian-core-identity.v1",
      "memories_by_chat_id": {},
      "previous_daily_summaries": [],
      "previous_notifications": [],
      "previous_interweek_reports": [],
      "monitored_profile": {}
    },
    "conversations": [
      {
        "chat": {
          "id": "group-1",
          "name": "1er ano",
          "type": "group",
          "description": "Grupo escolar",
          "participant_count": 24
        },
        "group": {
          "id": "group-1",
          "subject": "1er ano",
          "description": "Grupo escolar"
        },
        "metadata": {
          "group_identity": {
            "group_jid": "120363000000000000@g.us",
            "group_name": "1er ano"
          },
          "group_participants": [
            {
              "canonical_phone_jid": "5491111111111@c.us",
              "saved_contact_name": "Sofi Colegio",
              "is_saved": true
            }
          ]
        },
        "messages": [
          {
            "id": "msg-1",
            "timestamp": "2026-06-15T18:05:00-03:00",
            "sender": {"role": "contact", "id": "5491111111111@c.us", "name": "Sofi"},
            "type": "text",
            "text": "Hola Felipe"
          }
        ]
      }
    ]
  }
}

daily-summary-and-notifications-report

Use the target daily window. Core should send every conversation/message that should be considered for that day, plus explicit antecedents in payload.context when available.

Executable fixture: tests/fixtures/contracts/payload_daily_summary_and_notifications_report.json.

{
  "flow": "daily-summary-and-notifications-report",
  "recipe": "daily-summary-and-notifications-report-text-only",
  "settings": {"output_language": "es"},
  "payload": {
    "source": "core",
    "monitored": {"id": "minor-1", "name": "Felipe"},
    "user_stats_metadata": {
      "contract_version": "wardian-user-stats.v1",
      "top_contacts_by_messages": [
        {
          "chat_id": "chat-1",
          "name": "Sofi",
          "total_messages": 214,
          "monitored_messages": 81,
          "monitored_participation_ratio": 0.38
        }
      ],
      "top_view_once_chats_by_messages": [
        {
          "chat_id": "group-1",
          "name": "1er ano",
          "chat_type": "group",
          "total_view_once_messages": 28,
          "monitored_view_once_messages": 5
        }
      ],
      "night_messages": {
        "hour_range_local": "22:00-06:00",
        "total_messages": 12,
        "monitored_messages": 3
      }
    },
    "metadata": {
      "window_start": "2026-06-18T00:00:00-03:00",
      "window_end": "2026-06-19T00:00:00-03:00",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "context": {
      "identity_contract_version": "wardian-core-identity.v1",
      "memories_by_chat_id": {
        "chat-1": {
          "short_term_memory_recent_topics": {
            "summary": "El chat venia tratando temas escolares.",
            "active_threads": ["Tareas y organizacion escolar."],
            "relationship_context": "Contacto vinculado al colegio.",
            "open_questions": [],
            "last_observed_at": "2026-06-17"
          },
          "long_term_memory_relationship_and_others": {
            "summary": "Relacion escolar cotidiana.",
            "active_threads": [],
            "relationship_context": "Companera o grupo escolar.",
            "open_questions": [],
            "last_observed_at": "2026-06-17"
          }
        }
      },
      "previous_daily_summaries": [
        {"date": "2026-06-17", "summary": "Dia tranquilo con actividad escolar normal."}
      ],
      "previous_notifications": [],
      "previous_interweek_reports": [],
      "monitored_profile": {"grade": "1er ano"}
    },
    "conversations": [
      {
        "chat": {"id": "chat-1", "name": "Sofi", "type": "direct"},
        "metadata": {
          "shared_groups_with_contact": [
            {"id": "group-1", "subject": "1er ano", "description": "Grupo escolar"}
          ]
        },
        "messages": [
          {
            "id": "msg-1",
            "timestamp": "2026-06-18T18:05:00-03:00",
            "sender": {"role": "contact", "name": "Sofi"},
            "type": "text",
            "text": "Hoy estuvo tranquilo."
          }
        ]
      }
    ]
  }
}

interweek-report

Use explicit Core context for a 3/4/x-day synthesis. This flow does not require raw conversations in v1; the prompt receives monitored JSON, run metadata JSON, explicit context JSON and optional user stats metadata JSON.

Executable fixture: tests/fixtures/contracts/payload_interweek_report.json.

{
  "flow": "interweek-report",
  "recipe": "interweek-report-default",
  "settings": {"output_language": "es"},
  "payload": {
    "source": "core",
    "monitored": {"id": "minor-1", "name": "Felipe"},
    "user_stats_metadata": {
      "contract_version": "wardian-user-stats.v1",
      "window": {
        "start": "2026-06-15T00:00:00-03:00",
        "end": "2026-06-18T23:59:59-03:00",
        "timezone": "America/Argentina/Buenos_Aires"
      },
      "top_groups_by_messages": [
        {
          "chat_id": "group-1",
          "name": "1er ano",
          "total_messages": 486,
          "monitored_messages": 73,
          "monitored_participation_ratio": 0.15
        }
      ],
      "message_type_breakdown": {
        "text": 820,
        "audio": 36,
        "image": 91,
        "video": 12,
        "view_once": 31
      }
    },
    "metadata": {
      "period_start": "2026-06-15",
      "period_end": "2026-06-18",
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "context": {
      "identity_contract_version": "wardian-core-identity.v1",
      "previous_daily_summaries": [
        {"date": "2026-06-15", "summary": "Dia estable."},
        {"date": "2026-06-16", "summary": "Actividad escolar cotidiana."},
        {"date": "2026-06-17", "summary": "Sin novedades relevantes."}
      ],
      "previous_notifications": [
        {
          "date": "2026-06-16",
          "kind": "sin_novedad_relevante",
          "title": "Dia sin novedades relevantes",
          "chat_ids": ["chat-1"]
        }
      ],
      "previous_interweek_reports": [],
      "memories_by_chat_id": {
        "chat-1": {
          "short_term_memory_recent_topics": {
            "summary": "Temas escolares recientes.",
            "active_threads": ["Organizacion de tareas."],
            "relationship_context": "Contacto o grupo escolar.",
            "open_questions": [],
            "last_observed_at": "2026-06-17"
          }
        }
      },
      "monitored_profile": {"grade": "1er ano"}
    },
    "conversations": []
  }
}

Raw WAHA Payload

Use this shape when WAHA Core wants CIS to normalize events:

{
  "flow": "initial-ingestion-report",
  "recipe": "initial-ingestion-report-text-only",
  "payload": {
    "source": "waha",
    "monitored": {
      "id": "minor-1",
      "name": "Felipe",
      "phone": null
    },
    "metadata": {
      "window_start": "2026-06-01T00:00:00-03:00",
      "window_end": "2026-06-16T00:00:00-03:00"
    },
    "events": [
      {
        "event": "message.any",
        "payload": {
          "id": "msg-1",
          "timestamp": 1781557500,
          "from": "120363000000000000@g.us",
          "participant": "5491111111111@c.us",
          "participantName": "Sofi",
          "body": "Hola Felipe"
        }
      }
    ]
  }
}

Single webhook events are also accepted:

{
  "flow": "smoke-test",
  "payload": {
    "event": "message.any",
    "payload": {
      "id": "msg-1",
      "timestamp": 1781557500,
      "from": "120363000000000000@g.us",
      "participant": "5491111111111@c.us",
      "body": "Hola"
    }
  }
}

The adapter also accepts legacy context wrappers:

  • payload.context.menor as the monitored subject.
  • payload.context.metadata as top-level metadata.

WAHA event fields read by the adapter:

Raw fieldUsed for
eventEvent routing and message.metadata.event_name.
payload.idCanonical message.id; required for analyzable messages.
payload.timestampCanonical message timestamp.
payload.from, payload.to, payload.fromMeChat id and sender role.
payload.participant, payload.author, payload.fromContact sender id.
payload.participantName, payload.authorName, payload.pushName, payload.notifyNameContact display name.
payload.chatName, payload.groupNameChat display name metadata.
payload.body, payload.captionText/caption.
payload.media.url, payload.media.mimetype, payload.media.filename, payload.media.errorMedia URL, type and availability.
payload.type, payload.mimetypeFallback message type detection.
payload.quotedMessageId, payload.replyTo.idQuote correlation.
payload.isViewOnce, payload.isEphemeralMessage flags.
payload.vCards, payload.vcardContact card rendering.
payload.poll, payload.votePoll creation/vote rendering.
payload.location, payload.liveLocationLocation rendering.
payload.buttonResponse, payload.listResponseInteractive reply rendering.
payload.order, payload.productCommerce message rendering.
payload.eventResponse, payload.eventCreationKeyEvent response rendering.
payload.isVideo, payload.isGroupCall rendering.
payload.after, payload.revokedMessageId, payload.editedMessageId, payload.reactionSynthetic edit/revoke/reaction events.

See _docs/WAHA_INTEGRATION.md for supported and ignored event names plus the render mapping for structured WAHA events.

Broker Envelope

Kafka uses FlowRunEnvelope. It must include exactly one of:

  • payload: the same inline FlowRunRequest shown above.
  • payload_ref: a FlowArtifactRef pointing at a gzip-compressed JSON
  • FlowRunRequest in object storage.

Inline example:

{
  "contract_version": "content-intelligence.flow-runs.v1",
  "request_id": "request-1",
  "tenant_id": "tenant-1",
  "trace_id": "trace-1",
  "payload": {
    "flow": "daily-summary-and-notifications-report",
    "recipe": "daily-summary-and-notifications-report-text-only",
    "settings": {"output_language": "es"},
    "payload": {
      "source": "broker",
      "monitored": {"id": "minor-1", "name": "Felipe"},
      "metadata": {
        "window_start": "2026-06-15T00:00:00-03:00",
        "window_end": "2026-06-16T00:00:00-03:00"
      },
      "conversations": [
        {
          "chat": {"id": "chat-1", "name": "Sofi", "type": "direct"},
          "messages": [
            {
              "id": "m1",
              "timestamp": "2026-06-15T18:05:00-03:00",
              "sender": {"role": "contact", "name": "Sofi"},
              "type": "text",
              "text": "Hola"
            }
          ]
        }
      ]
    }
  }
}

Artifact example:

{
  "contract_version": "content-intelligence.flow-runs.v1",
  "request_id": "request-1",
  "tenant_id": "tenant-1",
  "trace_id": "trace-1",
  "payload_ref": {
    "source": "object_store",
    "bucket": "wardian-flow-artifacts",
    "object_key": "flow-artifacts/flow_run_payload/2026/07/04/request-1.json.gz",
    "content_type": "application/json",
    "compression": "gzip",
    "size_bytes": 123456,
    "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "artifact_kind": "flow_run_payload"
  }
}

bucket and object_key must be non-empty and must not contain whitespace or control characters. CIS validates size_bytes, sha256, content_type, compression and artifact_kind before hydrating the request. Decoded artifacts are capped by FLOW_ARTIFACT_MAX_DECODED_BYTES. Once hydrated, CIS validates and normalizes the request, removes repeated runtime-only identity metadata, and checks the compacted workflow input against TEMPORAL_WORKFLOW_INPUT_SAFE_BYTES. This permits a valid large artifact to proceed when its effective Temporal input is within budget; terminal workflow-input rejections publish a failed flow result instead of sending the valid artifact to the DLQ.

Producers still send one complete FlowRunRequest for the requested window; CP must not pre-chunk Daily conversations. With report auto-split enabled, CIS may convert an oversized eligible Daily request into internal wardian-report-split.v2 manifest/partition artifacts. V2 records media_identity_version: 2; the runtime retains read-only compatibility with v1 artifacts for histories already in flight. These schemas and their refs are not valid FlowRunEnvelope.payload_ref bodies and are never exposed to producers. Disabled or ineligible flows retain the existing bounded inline behavior. Split failures use stable report_* values in the existing open run/result error field; they do not add a new public contract version.

Validation And Failures

  • flow is required.
  • recipe, when provided, must belong to the selected flow.
  • payload.conversations must contain at least one conversation except for
  • interweek-report, which may run from payload.context, and daily payloads with authoritative zero activity.

  • Each conversation must contain at least one message.
  • Message ids must be unique inside one conversation.
  • Message content must have text, media URL or a supported structured event type.
  • Invalid media transports fail with media_url_transport_invalid before any
  • Temporal start or split-artifact write. The split planner repeats the check defensively and uses report_split_unsafe_media_reference if an internal unvalidated payload reaches it.

  • When a viable auto-split would persist effective media, query-signed or
  • tokenized media_url values must expose one unambiguous expiry covering the configured workflow deadline. Insufficient lifetime fails with report_split_media_url_ttl_insufficient; missing, malformed or conflicting signed expiry metadata fails with report_split_media_url_expiry_unverifiable. Stable remote URLs, media excluded by the effective recipe, and a request that safely falls back inline keep their existing contract. Error details never contain the URL or query.

  • Raw WAHA payloads without events and without a single event fail with
  • waha_events_required.

  • Raw WAHA batches with no analyzable message events fail with
  • waha_no_analyzable_messages.

  • Any explicit contract_version other than exact V1/V2 fails before workflow
  • launch; legacy compatibility applies only when the key and coverage are both absent. V1 must not carry source_coverage; V2 must carry a valid closed wardian-source-coverage.v1 object whose half-open window matches root stats and payload metadata.

  • A V2 operational coverage failure is represented by valid
  • status: "unverified" plus a closed reason code, never by omitting or malforming the V2 contract.

HTTP validation errors return 400. Broker validation errors go to the DLQ.

Privacy And State

  • Do not send media bytes in request payloads.
  • Do not rely on CIS as product memory; every run must carry explicit input.
  • Split transport state under flow-artifacts/report_split/ is temporary,
  • run-bound and lifecycle-limited; it is never an input to a later run and never contains raw media bytes.

  • debug_plaintext can expose plaintext/PII in debug surfaces during the
  • current development stage.

  • protected mode should prefer minimized/masked PII when producers can supply
  • it.

  • View-once and ephemeral flags are preserved as metadata, but they are not
  • standalone risk evidence.

Optional temporal side-contract

CP may add adapter_summary.temporal_evidence with contract/policy version, mode (disabled, shadow or enforce), eligible/resolved/quarantined counts and enum count maps. CIS also emits strict for direct raw-WAHA adaptation. When the block is present, versions, mode, non-negative integer counters and reason/confidence keys are validated as a closed contract; malformed blocks are rejected rather than treated as legacy input. In enforce mode every conversation message must include metadata.timestamp_provenance.timestamp_untrusted=false; otherwise validation fails before media or model work. Any enforced payload with quarantined events is rejected before no-activity handling or report generation, including when eligible conversations are also present. This prevents omitted evidence from becoming verified silence or incomplete chronology/statistics. Legacy payloads without this block remain valid for N-1 compatibility. In shadow/disabled, the temporal block is observational and does not filter conversations: supplied messages, including timestamp-untrusted evidence, may still reach prompts and reports.