Local resource

Configuration

_docs/CONFIGURATION.md

Configuration

Los fixtures de degradacion no agregan configuracion de runtime. Su importador es local, offline y create-only; no lee credenciales ni habilita modelos, red o Temporal. El gate de intake ENABLE_CIS_DEGRADATION_REPRO_INTAKE pertenece al Control Plane y queda false por defecto. Ver _docs/DEGRADATION_REGRESSION_FIXTURES.md.

Release metadata and health

The API reads release identity from CIS-specific variables so it never reports the version of another component in the shared alpha stack:

CIS_API_APP_RELEASE=
CIS_API_APP_GIT_SHA=
CIS_API_APP_GIT_SHORT_SHA=
CIS_API_APP_BUILD_TIME=

GET /health keeps its existing status, version, workflow_backend, and flows fields, identifies the component as service=wardian-content-intelligence-service, and also returns release, git_sha, git_short_sha, and build_time. The release metadata fields are null when not provided (for example, in a local development shell).

Runtime configuration is defined in services/settings.py.

Core

APP_ENV=development
DEV_MODE=false
WORKFLOW_BACKEND=temporal
REQUIRE_API_AUTH=true
API_BEARER_TOKEN=
CAPABILITIES_BEARER_TOKEN=
DEFAULT_FLOW_ID=initial-ingestion-report
DEFAULT_RECIPE_ID=initial-ingestion-report-text-only
RECIPE_ACTIVITY_TIMEOUT_SECONDS=1200
RUN_REPOSITORY_MAX_RECORDS=1000
RUN_REPOSITORY_MAX_CACHED_RESULT_BYTES=67108864

WORKFLOW_BACKEND=local is useful for tests and lightweight dev runs. Full media fan-out requires Temporal.

Internal report PDF renderer

REPORT_PDF_API_BEARER_TOKEN=
REPORT_PDF_MAX_REQUEST_BYTES=262144
REPORT_PDF_MAX_MARKDOWN_BYTES=204800
REPORT_PDF_MAX_OUTPUT_BYTES=5242880
REPORT_PDF_MAX_CONCURRENT_RENDERS=2
REPORT_PDF_RENDER_TIMEOUT_SECONDS=10
REPORT_PDF_RENDER_KILL_GRACE_MS=500
REPORT_PDF_RENDER_MEMORY_LIMIT_BYTES=268435456
REPORT_PDF_BUSY_RETRY_AFTER_SECONDS=10
REPORT_PDF_MAX_LIST_ITEMS=500
REPORT_PDF_MAX_LIST_DEPTH=4
REPORT_PDF_MAX_FLOWABLES=2000
REPORT_PDF_MAX_TABLE_CELLS=2000
REPORT_PDF_MAX_PAGES=100
REPORT_PDF_LIST_CHUNK_SIZE=25
REPORT_PDF_INITIAL_UPGRADE_URL=https://wardianapp.com/upgrade

POST /v1/internal/report-pdf/render is the stateless, in-memory renderer used by Control Plane for initial, daily, and interweek monitoring attachments. It accepts preview upgrade CTAs only when they exactly match the canonical REPORT_PDF_INITIAL_UPGRADE_URL configured for that environment. The URL must use HTTPS, a syntactically valid DNS/IP host and path, and cannot include whitespace, credentials, a port, query parameters, or a fragment. Because the upgrade block has a dedicated final page, REPORT_PDF_MAX_PAGES must be at least 2 and REPORT_PDF_MAX_FLOWABLES must be at least 31, the exact minimum for the header, one minimal Markdown block and the expanded initial-preview upgrade page. The endpoint always requires its own REPORT_PDF_API_BEARER_TOKEN; API_BEARER_TOKEN is intentionally not accepted.

GET /v1/internal/capabilities uses the separate CAPABILITIES_BEARER_TOKEN (minimum 32 UTF-8 bytes). Outside development-like environments the application refuses to start without it. See CAPABILITIES.md for the safe manifest contract. When configured, the dedicated token must contain at least 32 UTF-8 bytes. Only the API process needs this secret; broker and Temporal workers may omit it. A request reaching an API instance without the token fails closed with 503.

Capabilities v2 and flow-run v4 add no second privacy configuration source. The advertised assurance is derived from the existing PRIVACY_PROFILE, PRIVACY_ALLOW_RAW_DEBUG_IN_PROTECTED, CONTENT_RESOLUTION_MODE, and LANGFUSE_CAPTURE_MODEL_IO policy. protected assurance requires effective inline_masked resolution, raw debug disabled, raw model I/O disabled, and the existing invariant that raw media is never persisted; otherwise CIS advertises degraded or debug_plaintext and rejects an incompatible protected job.

The API that serves capabilities and every broker/Temporal component that can process the selected job must use the same image and effective privacy settings. Alpha deploys must render and compare those values before CP enforcement; do not mix v4 consumers with workers from an older privacy policy. Configuration changes alter the capabilities digest, so already-frozen jobs fail closed as stale instead of silently adopting the new policy. Redelivery of an existing intent-bound v4 run is the exception: CIS reuses its persisted original privacy decision and result. Temporal workers independently compare that decision with their local effective assurance before any prompt or model/media client work.

Each admitted render runs in a fresh spawn process. The API kills and reaps the child after REPORT_PDF_RENDER_TIMEOUT_SECONDS; a saturated instance returns report_pdf_renderer_busy with the configured Retry-After. Markdown that exceeds the list, nesting, table, flowable, or page budget fails deterministically with report_pdf_complexity_limit_exceeded. The worker keeps the request and PDF in memory and scrubs secret-bearing environment variables before rendering.

The strict request contract is:

{
  "contract_version": "wardian.report-pdf.render.v1",
  "template_id": "wardian.monitoring-daily.v1",
  "markdown_dialect": "wardian.cis-markdown.v1",
  "locale": "es-AR",
  "report_id": "report-id",
  "monitoring": {"id": "monitoring-id", "display_name": "Majo"},
  "window": {
    "start": "2026-07-09T00:00:00-03:00",
    "end": "2026-07-09T23:59:59-03:00",
    "timezone": "America/Argentina/Buenos_Aires"
  },
  "generated_at": "2026-07-10T12:00:00Z",
  "markdown": "# Resumen diario\n\nContenido canónico de monitoring_reports.markdown"
}

The v1 body remains compatible with the original daily renderer. template_id is the report-kind discriminator and accepts exactly:

  • wardian.monitoring-initial.v1
  • wardian.monitoring-daily.v1
  • wardian.monitoring-interweek.v1

All templates render the supplied markdown verbatim through the same Markdown dialect and Wardian A4 layout. They vary only in the report label, PDF metadata, and attachment filename. The generated filename date is the local generation date for initial reports, local window start for daily reports, and local window end for interweek reports.

Initial preview renders may also send an optional upgrade object. It is valid only with wardian.monitoring-initial.v1, requires the exact URL configured in REPORT_PDF_INITIAL_UPGRADE_URL, and appends a dedicated final page after the report Markdown. The literal below is the default value:

{
  "kind": "initial_preview",
  "cta_url": "https://wardianapp.com/upgrade",
  "preview_history_days": 7,
  "full_history_days": 21,
  "skipped_media_total": 163,
  "skipped_media_breakdown": [
    {"label": "stickers", "count": 78},
    {"label": "imágenes", "count": 49},
    {"label": "links", "count": 28}
  ],
  "skipped_media_other_count": 8,
  "media_evidence_status": "available",
  "flagged_categories": [
    {"id": "grooming", "status": "⚠️", "summary": "Una conversación necesita más contexto."},
    {"id": "bullying", "status": "👀", "summary": "Conviene mirar si el patrón se repite."}
  ],
  "flagged_categories_other_count": 1,
  "category_evidence_status": "available",
  "pricing": {
    "version": "wardian.initial-report-upgrade-pricing.v1",
    "monthly": {"label": "Mensual", "price": "US$ 6,99/mes"},
    "annual": {
      "label": "Anual",
      "price": "US$ 29/año",
      "badge": "Precio de lanzamiento",
      "recommendation": "Recomendado"
    }
  }
}

The breakdown is capped at three rows and must reconcile exactly with the total and skipped_media_other_count. Flagged categories are capped at three canonical taxonomy ids, accept only ⚠️ or 👀, and use summaries of at most 180 characters. The fixed available|empty|unavailable states distinguish real evidence, a real empty result and unavailable evidence. The renderer uses neutral wording for unavailable evidence and never fabricates categories or counts. Pricing is an exact versioned contract and is not model output.

Wire compatibility remains within wardian.report-pdf.render.v1: upgrade objects queued before initial_preview_upgrade.v2 may omit the two evidence states, categories and pricing. CIS infers the legacy media state from its existing total, treats categories as unavailable and supplies the immutable v1 launch pricing. Explicit v2 states stay strict: available media requires a positive total, and displayed plus omitted categories can never exceed the 18 canonical taxonomy ids.

A successful response is raw application/pdf with Cache-Control: no-store, an attachment filename, X-Request-ID, and the contract, selected template, renderer, Markdown dialect, and SHA-256 headers. Errors never echo request content and use {"error":{"code":"...","retryable":false,"request_id":"..."}}. Request, Markdown, output, and concurrency limits fail closed with 413 or 429. Invalid contracts, deterministic Markdown/input render failures, and unsupported glyphs return a non-retryable 422. Missing brand assets, renderer credentials, worker failures and internal invalid render results return retryable 503 responses with Retry-After; Control Plane keeps the same outbox row parked until a later render succeeds.

Rendering uses only repository-local Wardian assets plus the tracked OFL Noto Emoji and Noto Sans Symbols 2 fallbacks. It does not download Markdown images, write temporary files, or persist PDF/Markdown bytes in storage, DB, Kafka, Temporal, logs, or traces.

RUN_REPOSITORY_MAX_RECORDS (default 1000) and RUN_REPOSITORY_MAX_CACHED_RESULT_BYTES (default 64 MiB) bound the in-memory run status repository. The full request is never retained in that repository; flow/recipe identifiers remain available for polling. Completed-result cache pressure evicts older terminal records before in-flight runs, while the just-completed result remains available to its current caller. Evicted run ids reconcile from Temporal when possible.

RECIPE_ACTIVITY_TIMEOUT_SECONDS controls the Temporal text activity timeout for newly launched flow recipe executions. Launchers copy this setting into the workflow payload so Temporal replay stays deterministic; the workflow fallback remains the fixed 240 second default for histories that do not carry the payload field. The 1200 second runtime default leaves bounded windows for entity extraction and semantic trust evaluation, including one corrective JSON request. Configurations that enable extraction but cannot fit its deadline plus the required downstream reserve fail validation instead of silently skipping it.

Models

OPENROUTER_API_KEY=
GROQ_API_KEY=
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
GROQ_BASE_URL=https://api.groq.com/openai/v1
FLOW_PRIMARY_MODEL=google/gemini-3.5-flash
FLOW_SHADOW_MODEL=google/gemini-3.1-flash-lite
FLOW_MAX_OUTPUT_TOKENS=32768
FLOW_GENERATION_TEMPERATURE=0.35
OPENROUTER_PRESET=main-preset
OPENROUTER_REQUIRE_PARAMETERS=true
MODEL_REQUEST_TIMEOUT_SECONDS=120
OPENROUTER_TEXT_MAX_RETRIES=2
CHAT_ID_SUMMARIZATION_PRIMARY_MODEL=
CHAT_ID_SUMMARIZATION_SHADOW_MODEL=
CHAT_ID_SUMMARIZATION_BATCH_SIZE=1
CHAT_ID_SUMMARIZATION_BATCH_MAX_PROMPT_CHARS=30000
CHAT_ID_SUMMARIZATION_MAX_RETRIES=2
OPENROUTER_VISION_MODEL=meta-llama/llama-4-scout
OPENROUTER_VISION_DETAIL=low
OPENROUTER_VISION_REQUEST_TIMEOUT_SECONDS=20
OPENROUTER_VISION_MAX_RETRIES=1
VISION_FRAME_CONCURRENCY=2
VIDEO_PROCESSOR_BACKEND=gemini
OPENROUTER_VIDEO_MODEL=google/gemini-3.1-flash-lite
OPENROUTER_VIDEO_MEDIA_RESOLUTION=MEDIA_RESOLUTION_LOW
OPENROUTER_VIDEO_INLINE_MAX_BYTES=20000000
OPENROUTER_VIDEO_REQUEST_TIMEOUT_SECONDS=90
OPENROUTER_VIDEO_MAX_RETRIES=1
OPENROUTER_VIDEO_ACTIVITY_RESERVE_SECONDS=60
VIDEO_MAX_OUTPUT_TOKENS=2048
ANIMATED_MEDIA_ENCODE_FPS=1
GROQ_AUDIO_DEFAULT_MODEL=whisper-large-v3-turbo
GROQ_AUDIO_FALLBACK_MODEL=whisper-large-v3

OPENROUTER_BASE_URL and GROQ_BASE_URL override the provider API endpoints (for proxies or OpenAI-compatible gateways). The defaults above are the canonical public endpoints; the values must be non-empty. The default model pair (FLOW_PRIMARY_MODEL / FLOW_SHADOW_MODEL) is defined once in services/settings.py; services/flows.py derives its step defaults from those settings defaults.

CHAT_ID_SUMMARIZATION_PRIMARY_MODEL and CHAT_ID_SUMMARIZATION_SHADOW_MODEL override the model used only for the chat_id_summarization step; when unset (default) it falls back to FLOW_PRIMARY_MODEL / FLOW_SHADOW_MODEL. This lets a stronger model generate chat memories, or lets a candidate run in the shadow slot for A/B comparison without touching the rest of the flow. The same model selection applies to the isolated daily memory request used when monitored age-band personalization is active; that request never receives the age band.

OPENROUTER_REQUIRE_PARAMETERS=true keeps provider routing fail-fast by asking OpenRouter to select only providers that support the requested structured-output parameters. Deployments using an OpenRouter-compatible model/provider that rejects that provider option can set it to false; the JSON Schema response format is still sent.

CHAT_ID_SUMMARIZATION_BATCH_SIZE controls the maximum chat count per internal batch for the logical chat_id_summarization step in initial-ingestion-report. CHAT_ID_SUMMARIZATION_BATCH_MAX_PROMPT_CHARS also limits the estimated prompt size, so a very large chat can be sent alone. Documents are ordered by message volume (desc) before batching, so high-volume chats fill the char budget first and land in smaller batches (more context per chat) while the low-volume tail packs together. Neither setting changes the public output contract; the runner merges batches in memory before returning chat_id_summaries and chat_id_memory_updates.

MODEL_REQUEST_TIMEOUT_SECONDS bounds each OpenRouter flow-text request attempt. OPENROUTER_TEXT_MAX_RETRIES (default 2, range 0..5) is passed explicitly to those SDK calls so their retry envelope cannot drift with a library default. Whole-video requests use the dedicated OPENROUTER_VIDEO_REQUEST_TIMEOUT_SECONDS (default 90) and OPENROUTER_VIDEO_MAX_RETRIES (default 1). Startup rejects a combined whole-video SDK retry budget that exceeds the 300-second media Activity timeout after reserving OPENROUTER_VIDEO_ACTIVITY_RESERVE_SECONDS (default 60) for download, verification, probing, and local file work. Structured flow JSON generation can make two SDK calls (one corrective JSON retry); split startup validates the reducer timeout against primary plus shadow and, when enabled, both trust-evaluator calls, including worst-case SDK attempts and an artifact reserve.

Vision uses its own tighter OPENROUTER_VISION_REQUEST_TIMEOUT_SECONDS because the vision Temporal activity has a shorter timeout than the text flow activity. OPENROUTER_VISION_MAX_RETRIES configures SDK retries for transient provider failures; the SDK honors provider Retry-After guidance. Separately, a provider 400 that specifically rejects media_resolution triggers exactly one capability fallback request without image_url.detail. If that fallback is also rejected for the same capability, the media item degrades with vision_provider_capability_mismatch and the flow continues.

VISION_FRAME_CONCURRENCY bounds simultaneous model requests within each animated-image or video message. It does not change frame sampling or output shape. Total process-level pressure remains the product of worker activity concurrency and this per-message limit, so alpha should pair the default frame limit 2 with a deliberately bounded vision/video worker concurrency.

Model usage and cost attribution is configured in code, not environment variables. services/pricing.py contains the OpenRouter per-token rates used for text/vision fallback cost calculation and the Groq per-hour ASR rates used for audio transcription. Refresh that table from OpenRouter's models API and Groq pricing docs whenever model ids or providers change.

Video uses OpenRouter by default. MEDIA_RESOLUTION_LOW is the cost-efficient default validated by the migration A/B; MEDIA_RESOLUTION_HIGH materially increases input tokens. OpenRouter ignores the tested fps field, so runtime does not send it. ANIMATED_MEDIA_ENCODE_FPS controls only local animated GIF/WebP/APNG conversion and defaults to 1.

Each FlowRunRequest.settings.video_processing may override backend (gemini or legacy), media_resolution (full MEDIA_RESOLUTION_* enum), and local animated-media fps. API/provider failures remain retryable Temporal errors. The legacy fallback is only automatic for content over the inline byte limit.

Migration measurements taken through OpenRouter on 2026-07-24:

Synthetic 4 s requestInput tokensOutput tokensReported cost
No media parameters26917USD 0.00009275
MEDIA_RESOLUTION_LOW, local/test fps=126914-17USD 0.0000885-0.00009275
MEDIA_RESOLUTION_HIGH103818USD 0.0002865

Changing the tested fps value did not change input tokens or cost, including an invalid string value accepted by the gateway, so it is intentionally absent from the OpenRouter request. A repository corpus MP4 of 78.3 seconds processed through the final implementation at LOW used 7254 input and 327 output tokens, with USD 0.002304 reported cost. These measurements are evidence for the default, not a permanent price guarantee.

Langfuse

LANGFUSE_ENABLED=false
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_BASE_URL=
LANGFUSE_CAPTURE_MODEL_IO=true
LANGFUSE_PROMPT_MANAGEMENT_ENABLED=false
LANGFUSE_PROMPT_LABEL=production

Langfuse observes only model requests. The service sends native usage_details and cost_details on model generations so Langfuse can populate Cost, Input Cost, Output Cost, and token usage columns. The Langfuse UI may still round dashboard cards; use observation details, exports, or APIs when more than two decimal places are required. LANGFUSE_CAPTURE_MODEL_IO also controls whether raw request/response payloads, including audio transcripts and original audio source URLs, are attached to Langfuse outputs when privacy policy allows that capture.

Temporal

TEMPORAL_HOST=localhost:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=orchestration
TEMPORAL_UI_BASE_URL=http://localhost:8234
TEMPORAL_WORKFLOW_INPUT_SAFE_BYTES=3500000
TEMPORAL_ACTIVITY_INPUT_SAFE_BYTES=1500000
TEMPORAL_MEDIA_ACTIVITY_RESULT_SAFE_BYTES=500000
TEMPORAL_MIN_EVENT_AT=2020-01-01T00:00:00Z
TEMPORAL_FUTURE_SKEW_SECONDS=600
REPORT_TEMPORAL_AUTOSPLIT_ENABLED=false
REPORT_TEMPORAL_AUTOSPLIT_FLOWS=daily-summary-and-notifications-report
REPORT_PARTITION_TARGET_BYTES=1000000
REPORT_PARTITION_ENVELOPE_HEADROOM_BYTES=65536
REPORT_SPLIT_MAX_PARTITIONS=64
REPORT_SPLIT_MAX_CONCURRENCY=3
REPORT_SPLIT_MAX_MEDIA_ITEMS_PER_PARTITION=3
REPORT_SPLIT_PARTITION_HISTORY_BUDGET_BYTES=10000000
REPORT_SPLIT_MAX_MERGED_MEDIA_BYTES=8388608
REPORT_SPLIT_MAX_TOTAL_DECODED_BYTES=33000000
REPORT_SPLIT_PREPARED_REQUEST_ENVELOPE_RESERVE_BYTES=65536
REPORT_SPLIT_FINAL_RESULT_RESERVE_BYTES=4194304
REPORT_SPLIT_FINAL_RESULT_COMPRESSED_RESERVE_BYTES=1048576
REPORT_SPLIT_ARTIFACT_RETENTION_DAYS=8
REPORT_SPLIT_ARTIFACT_IO_MAX_ATTEMPTS=3
REPORT_SPLIT_ARTIFACT_IO_RETRY_BACKOFF_SECONDS=0.25
REPORT_SPLIT_ARTIFACT_READ_CONCURRENCY=32
REPORT_PROMPT_MAX_ESTIMATED_TOKENS=750000
REPORT_SPLIT_PREPARE_ACTIVITY_TIMEOUT_SECONDS=900
REPORT_REDUCE_ACTIVITY_TIMEOUT_SECONDS=2100
REPORT_WORKFLOW_EXECUTION_TIMEOUT_SECONDS=43200
REPORT_SPLIT_MEDIA_URL_EXPIRY_HEADROOM_SECONDS=300
MEDIA_MAX_ITEMS_PER_CHUNK=50
CIS_QUIESCE_MARKER_PATH=/run/wardian/cis-rollout/quiesced
CIS_QUIESCE_POLL_INTERVAL_SECONDS=1
API_GRACEFUL_SHUTDOWN_SECONDS=120
BROKER_GRACEFUL_SHUTDOWN_SECONDS=960
WORKER_TEXT_CONCURRENCY=32
WORKER_ORCHESTRATION_GRACEFUL_SHUTDOWN_SECONDS=120
WORKER_TEXT_GRACEFUL_SHUTDOWN_SECONDS=360
WORKER_REPORT_SPLIT_CONCURRENCY=1
WORKER_REPORT_SPLIT_GRACEFUL_SHUTDOWN_SECONDS=2400
WORKER_VISION_CONCURRENCY=16
WORKER_VISION_GRACEFUL_SHUTDOWN_SECONDS=360
WORKER_AUDIO_CONCURRENCY=12
WORKER_AUDIO_GRACEFUL_SHUTDOWN_SECONDS=360
WORKER_DOCUMENT_CONCURRENCY=8
WORKER_DOCUMENT_GRACEFUL_SHUTDOWN_SECONDS=360
WORKER_VIDEO_CONCURRENCY=4
WORKER_VIDEO_GRACEFUL_SHUTDOWN_SECONDS=360

PostgreSQL in docker-compose.yml is for Temporal service state.

Every runtime container mounts the root-managed rollout state directory read-only. While CIS_QUIESCE_MARKER_PATH exists, API liveness remains available at /health, readiness returns 503 at /ready, mutating API requests return retryable 503, brokers stop fetching after their current message, and a new Temporal worker waits before polling. An unreadable, malformed, non-regular, or non-root-owned marker is interpreted as quiesced. The deploy owner must remove only the marker for its exact operation after proving the drain.

The API, broker, orchestration, text, audio, vision, document, video, and report-split graceful settings must match their Compose stop_grace_period. Media/text workers reserve 360 seconds for the five-minute activity budget, orchestration and API reserve 120 seconds, and broker grace must cover the largest result polling window plus 60 seconds. Report-split retains its stricter reduce timeout plus 300-second rule.

The three *_SAFE_BYTES settings keep workflow start, individual media results, and the final text activity below Temporal's hard payload limits with headroom. MEDIA_MAX_ITEMS_PER_CHUNK is the maximum deterministic media wave; all matching items are still processed. Invalid values fail settings startup.

Direct raw-WAHA adaptation accepts only semantic event clocks between TEMPORAL_MIN_EVENT_AT and the current validation time plus TEMPORAL_FUTURE_SKEW_SECONDS. The current time is only an upper validation bound; it is never substituted as an event timestamp. The minimum must be an offset-aware RFC3339 value and future skew must remain between 0 and 86400 seconds.

Report auto-split is an internal Daily execution mode and is disabled by default. When enabled, REPORT_PARTITION_TARGET_BYTES plus REPORT_PARTITION_ENVELOPE_HEADROOM_BYTES must fit within TEMPORAL_ACTIVITY_INPUT_SAFE_BYTES; concurrency cannot exceed 64 or the partition count, and partition count cannot exceed 64. The prepare stage loads only one bounded wave of partition/result artifacts at a time on its dedicated single-concurrency worker. Partition hydrate keeps its two-minute Activity timeout and three Temporal attempts. Each attempt performs one in-process attempt for each of the three sequential artifact reads; startup reserves ten seconds for validation and rejects storage timeout settings whose complete hydrate read budget would not fit the remaining 110 seconds. Prepare/reduce retain their configured internal artifact retries. The startup validator conservatively estimates child history from the partition payload, media count, activity-input cap and media-result cap; it must fit REPORT_SPLIT_PARTITION_HISTORY_BUDGET_BYTES (10 MB by default). Only media enabled by the effective recipe/override policy counts toward that cap. The workflow deadline covers all configured partition waves, media waves, activity retries and reducer timeouts with 25% headroom; invalid combinations fail startup. REPORT_SPLIT_MAX_TOTAL_DECODED_BYTES plus REPORT_SPLIT_PREPARED_REQUEST_ENVELOPE_RESERVE_BYTES must not exceed FLOW_ARTIFACT_MAX_DECODED_BYTES; the default raw-request cap is 33,000,000 bytes. The target, activity envelope budget, total decoded cap and artifact-read concurrency are snapshotted into each manifest so a run uses its launch budgets.

Merged derived-media JSON is compacted deterministically across all partitions to REPORT_SPLIT_MAX_MERGED_MEDIA_BYTES without dropping item identities; if even the minimal projection cannot fit, the run fails before model generation. The pre-model result shell plus REPORT_SPLIT_FINAL_RESULT_RESERVE_BYTES must fit under FLOW_ARTIFACT_MAX_DECODED_BYTES, and the actual final result is checked again before upload. The merged-media cap plus reserve must remain strictly below the artifact decoded limit. The pre-model shell is also gzip-sized with REPORT_SPLIT_FINAL_RESULT_COMPRESSED_RESERVE_BYTES; the actual final gzip is checked again against FLOW_ARTIFACT_MAX_BYTES before upload.

Transient object-store operations retry in-process up to REPORT_SPLIT_ARTIFACT_IO_MAX_ATTEMPTS, with exponential backoff starting at REPORT_SPLIT_ARTIFACT_IO_RETRY_BACKOFF_SECONDS. Permanent schema, digest, bucket, prefix and capacity failures are non-retryable. Startup rejects retry configurations whose cumulative backoff exceeds 30 seconds. Every S3 request is also bounded by FLOW_ARTIFACT_CONNECT_TIMEOUT_SECONDS (3), FLOW_ARTIFACT_READ_TIMEOUT_SECONDS (15) and FLOW_ARTIFACT_SDK_MAX_ATTEMPTS (1). The idempotent prepare Activity reads partition artifacts in bounded parallel waves, drains sibling operations on failure, and has up to three Temporal attempts under REPORT_SPLIT_PREPARE_ACTIVITY_TIMEOUT_SECONDS (900). Startup checks the exact read/write retry-wave budget against that timeout.

REPORT_PROMPT_MAX_ESTIMATED_TOKENS fails an oversized recomposed prompt before any report model call. Preparation persists a bounded recomposed-request ref and merged-media ref; the reducer hydrates only the manifest plus those two refs before sequential primary/shadow calls. REPORT_REDUCE_ACTIVITY_TIMEOUT_SECONDS defaults to 2100 seconds and covers those reads, final write and the explicit provider retry budget. The reducer has one Temporal attempt to avoid duplicate generation after an ambiguous timeout. Preparation and reduction run on the dedicated report-split-processing queue served by worker-report-split, whose Activity concurrency defaults to 1 via WORKER_REPORT_SPLIT_CONCURRENCY; startup caps the product of worker and S3 read concurrency at 64. REPORT_WORKFLOW_EXECUTION_TIMEOUT_SECONDS (default 43200) is the real terminal deadline for eligible Daily workflows. WORKER_REPORT_SPLIT_GRACEFUL_SHUTDOWN_SECONDS defaults to 2400 and must cover the 2100-second reducer timeout plus 300 seconds. The worker handles SIGTERM and SIGINT by asking the Temporal SDK to drain active Activities; compose gives the container the same stop grace. This protects the one-attempt reducer during an image update without making the model call replayable. The alpha restricted deploy requires both the general com.wardian.cis.rollout-drain-contract=1 image capability and the compatible report-split label. Its operator-only drain bounds default to 3000 seconds total query time, 10 seconds between visibility checks, three consecutive empty checks, and 30 seconds for each host-side and Temporal CLI Visibility query via ALPHA_CIS_REPORT_SPLIT_DRAIN_QUERY_TIMEOUT_SECONDS; see _docs/ALPHA_DEPLOY.md for the fail-closed stopped state on timeout. For effective media items, query-signed or tokenized media_url values must expose a parseable expiry covering that full deadline. The launcher checks the deadline plus REPORT_SPLIT_MEDIA_URL_EXPIRY_HEADROOM_SECONDS (default 300) before the first artifact write, then checks the full deadline again after the manifest write and immediately before Temporal start. AWS/MinIO and Google duration signatures, Azure SAS timestamps, and generic/CloudFront epoch expiries are recognized case-insensitively. Ambiguous, duplicate, malformed or unverifiable signed expiries fail closed; stable non-auth query parameters and media excluded by the effective recipe do not. The inline fallback does not apply a split-only TTL requirement. Upstream URL issuers must therefore mint at least 44400 seconds with the defaults; S3_PRESIGNED_URL_TTL_SECONDS=900 is not sufficient when its output is used as media input for an auto-split run. Startup now rejects an enabled autosplit configuration unless this TTL covers the 900-second bounded split preflight, REPORT_WORKFLOW_EXECUTION_TIMEOUT_SECONDS, plus REPORT_SPLIT_MEDIA_URL_EXPIRY_HEADROOM_SECONDS; the exact boundary is valid. REPORT_SPLIT_ARTIFACT_RETENTION_DAYS is a deployment contract, not an in-process deletion scheduler. Before turning the flag on, verify an object store lifecycle on flow-artifacts/report_split/. When v2 reference emission is enabled, startup requires this retention to outlive FLOW_ARTIFACT_REF_MAX_TTL_SECONDS plus clock skew. Roll back by disabling the flag for new runs while keeping the new workers and lifecycle in place until existing split workflows drain. Broker redeliveries which resolve to an existing allowlisted Daily remain conservatively non-terminal while they drain, even after the launch flag is off; a newly launched Daily with the flag off keeps the ordinary terminal broker timeout.

Rollout is deliberately two-phase because payload-size version 2 changes workflow command decisions at exact boundaries. First deploy the new image to every Temporal worker plus API/broker, provision worker-report-split locally and cis-report-split-worker in the external alpha compose, and keep REPORT_TEMPORAL_AUTOSPLIT_ENABLED=false; launchers continue writing version 1 while that flag is off. After all old workers are gone and result-ref hydration, artifact lifecycle and broker guards are verified, enable the flag and restart the launchers/workers. Never enable it during a mixed old/new worker rollout. On rollback, turn the flag off for new runs but keep the new worker code running until all version-2/split histories have closed.

The external alpha service must run python -m workers.report_split_worker, consume report-split-processing, and receive the same split/artifact env vars as the other CIS services. The restricted alpha deploy script deliberately requires this service, so deployment fails closed if the out-of-tree compose has not been provisioned.

Broker And Shared Artifact Storage

KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=wardian-content-intelligence-service
CI_ENABLE_BATCH_INGRESS=false
CI_ENABLE_RESULT_PUBLISHER=false
MONITORED_AGE_BAND_CONTEXT_ENABLED=false
REPORT_FEEDBACK_CONTEXT_RENDERER_ENABLED=false
ENTITY_EXTRACTION_ENABLED=false
ENTITY_EXTRACTION_PRIMARY_MODEL=
ENTITY_EXTRACTION_SHADOW_MODEL=
ENTITY_EXTRACTION_BATCH_MAX_MESSAGES=200
ENTITY_EXTRACTION_BATCH_MAX_PROMPT_CHARS=30000
ENTITY_EXTRACTION_TOTAL_TIMEOUT_SECONDS=300
CONTENT_INTELLIGENCE_FLOW_RUNS_TOPIC=content-intelligence.flow-runs.v1
CONTENT_INTELLIGENCE_FLOW_RUNS_TOPICS=
CONTENT_INTELLIGENCE_FLOW_RESULTS_TOPIC=content-intelligence.flow-results.v1
CONTENT_INTELLIGENCE_FLOW_DIAGNOSTICS_TOPIC=content-intelligence.flow-diagnostics.v1
CONTENT_INTELLIGENCE_PROMPT_LAB_RESULTS_TOPIC=content-intelligence.prompt-lab-results.v1
CONTENT_INTELLIGENCE_FLOW_RUNS_DLQ_TOPIC=content-intelligence.flow-runs.dlq.v1
CONTENT_INTELLIGENCE_FLOW_RUNS_CONSUMER_GROUP=wardian-cis-flow-runs
BROKER_RESULT_TIMEOUT_SECONDS=900
BROKER_SPLIT_RESULT_TIMEOUT_SECONDS=900
BROKER_KAFKA_MAX_REQUEST_SIZE_BYTES=2097152
BROKER_KAFKA_MAX_POLL_INTERVAL_MS=1800000
BROKER_KAFKA_MAX_POLL_RECORDS=1
BROKER_KAFKA_CONSUMER_REQUEST_TIMEOUT_MS=60000
BROKER_KAFKA_SESSION_TIMEOUT_MS=45000
BROKER_KAFKA_HEARTBEAT_INTERVAL_MS=10000
BROKER_KAFKA_RETRY_BACKOFF_MS=500
BROKER_KAFKA_CONSUMER_RESTART_MAX_BACKOFF_SECONDS=30
BROKER_KAFKA_METADATA_MAX_AGE_MS=300000
BROKER_KAFKA_RESULT_REFS_ENABLED=false
BROKER_KAFKA_INLINE_RESULT_MAX_BYTES=786432
FLOW_ARTIFACTS_BUCKET=wardian-flow-artifacts
FLOW_ARTIFACT_MAX_BYTES=8388608
FLOW_ARTIFACT_MAX_DECODED_BYTES=33554432
FLOW_ARTIFACT_CONNECT_TIMEOUT_SECONDS=3
FLOW_ARTIFACT_READ_TIMEOUT_SECONDS=15
FLOW_ARTIFACT_SDK_MAX_ATTEMPTS=1
FLOW_ARTIFACT_REF_POLICY_MODE=legacy
FLOW_ARTIFACT_REF_V2_EMIT_ENABLED=false
FLOW_ARTIFACT_REF_TTL_SECONDS=604800
FLOW_ARTIFACT_REF_MAX_TTL_SECONDS=604800
FLOW_ARTIFACT_REF_CLOCK_SKEW_SECONDS=60
FLOW_ARTIFACT_RETENTION_SECONDS=691200
FLOW_ARTIFACT_SERVER_SIDE_ENCRYPTION=AES256
FLOW_ARTIFACT_SSE_KMS_KEY_ID=
FLOW_RESULT_EVIDENCE_ENABLED=false
FLOW_RESULT_EVIDENCE_STORAGE_POLICY_VERIFIED=false
FLOW_RESULT_EVIDENCE_TTL_SECONDS=86400
FLOW_RESULT_EVIDENCE_RETENTION_SECONDS=172800
FLOW_RESULT_EVIDENCE_UPLOAD_GRACE_SECONDS=900
FLOW_RESULT_EVIDENCE_UPLOAD_MAX_ATTEMPTS=3
SAFE_FAILURE_ENVELOPE_MODE=off
CIS_CONVERSATION_MEMORY_V2_MODE=off
FLOW_ARTIFACT_CP_TO_CIS_HMAC_KEYS=
FLOW_ARTIFACT_CP_TO_CIS_ACTIVE_KEY_ID=
FLOW_ARTIFACT_CIS_TO_CP_HMAC_KEYS=
FLOW_ARTIFACT_CIS_TO_CP_ACTIVE_KEY_ID=
FLOW_INTENT_GUARD_MODE=off
FLOW_INTENT_LEGACY_RUN_POLICY=allow
FLOW_INTENT_CP_TO_CIS_HMAC_KEYS=
CONTENT_TRUST_MODE=off
CONTENT_TRUST_EVALUATOR_ENABLED=false
CONTENT_TRUST_EVALUATOR_MODEL=
CONTENT_TRUST_EVALUATOR_MAX_INPUT_CHARS=120000

SAFE_FAILURE_ENVELOPE_MODE accepts off, observe, or emit and rejects any other value at startup. off preserves the legacy failed-result envelope. observe performs the privacy-safe classification and records structured measurements without changing Kafka payloads. emit still requires each v2, v4, or v5 request to opt in with result_delivery.safe_failure_contract=wardian.safe-failure.v1; without that consumer capability, CIS publishes the N-1 shape. Roll out the Control Plane consumer first, deploy CIS with the default off, then use observe, and only then enable emit. Rollback is the reverse flag change and requires no data migration.

CIS_CONVERSATION_MEMORY_V2_MODE accepts off, observe, or enforce and rejects any other value at startup. off keeps the legacy memory prompt/output and does not advertise the capability. observe advertises and validates v2, computes only privacy-safe ACK measurements, and leaves prompt/output behavior legacy. enforce uses negotiated v2 Daily slots and emits the bounded ACK before result digests. Roll out consumer-first and preserve legacy fallback for jobs that did not freeze conversation_memory.v2.

MONITORED_AGE_BAND_CONTEXT_ENABLED is the consumer-first rollout gate for wardian-monitored-age-band.v1. When false, known bands are accepted but do not render. When true, only a valid known band renders in report prompts; unknown/invalid values and DOB/exact age never become model input.

REPORT_FEEDBACK_CONTEXT_RENDERER_ENABLED is the consumer-first rollout gate for the existing payload.context.report_feedback array. When false, CIS keeps the N-1 generic-context behavior. When true, CIS removes that key from generic context, validates and bounds individual items, and renders only the dedicated soft-context section in initial, daily and InterWeek prompts. Invalid/empty input is omitted fail-open. Enable CIS first, then CP context emission; rollback stops CP emission before disabling this consumer flag. No envelope, topic or result contract changes and no feedback receipt is emitted.

ENTITY_EXTRACTION_ENABLED adds a deterministic, non-parent-facing entity_extraction request to initial and daily flows. It consumes only the validated wardian-entity-catalog.v1 context plus current conversation evidence and emits additive wardian-entity-updates.v1. Work is split deterministically in conversation order and then every ENTITY_EXTRACTION_BATCH_MAX_MESSAGES messages (default 200). ENTITY_EXTRACTION_BATCH_MAX_PROMPT_CHARS (default 30000) also splits batches by their rendered request size; a single oversized message is explicitly clipped with a truncation marker, and the optional catalog projection is reduced first when necessary. Each batch is validated independently; successful candidates are merged and deduplicated with their evidence, while a failed batch is logged and does not erase successful batches. It never runs for InterWeek or when the global identity snapshot or any current conversation abstains. The optional model variables override the normal flow models for this step. Roll out the CP consumer first, then enable CIS extraction, then enable CP catalog/portal exposure. ENTITY_EXTRACTION_TOTAL_TIMEOUT_SECONDS (default 300) bounds the entire non-fatal side-step across all batches. When the deadline expires, CIS keeps already validated candidates and skips the remaining batches; autosplit startup validation reserves that same wall-clock budget inside REPORT_REDUCE_ACTIVITY_TIMEOUT_SECONDS.

CONTENT_INTELLIGENCE_FLOW_RUNS_TOPIC may contain a comma-separated list, or CONTENT_INTELLIGENCE_FLOW_RUNS_TOPICS can be set to override it. This lets one broker worker consume CP lane topics such as initial, daily and interweek.

BROKER_KAFKA_MAX_POLL_INTERVAL_MS and BROKER_KAFKA_MAX_POLL_RECORDS tune the Kafka consumer poll window. Alpha keeps one in-flight flow run per broker and a long poll interval so slow daily processing does not lose group membership before the broker can commit after publishing the result.

The consumer transport and membership timers are explicit and validated. BROKER_KAFKA_HEARTBEAT_INTERVAL_MS must be lower than BROKER_KAFKA_SESSION_TIMEOUT_MS, and the consumer request timeout must exceed the session timeout. Keep lane-specific values aligned unless a canary is being run. BROKER_KAFKA_RETRY_BACKOFF_MS controls client retries after transient broker errors and is also the initial in-process consumer restart delay. Restart backoff grows exponentially up to BROKER_KAFKA_CONSUMER_RESTART_MAX_BACKOFF_SECONDS (default 30, valid range greater than zero through 3600). It does not retry completed Temporal work or change Kafka offset commit semantics; non-Kafka and non-transport application failures still exit for supervisor handling.

BROKER_RESULT_TIMEOUT_SECONDS (default 900) remains terminal for ordinary flows. For an allowlisted Daily while auto-split is enabled, BROKER_SPLIT_RESULT_TIMEOUT_SECONDS is a non-terminal polling window: if Temporal is still running, CIS emits broker_split_result_pending, publishes nothing, leaves the Kafka offset uncommitted and exits so supervisor redelivery reattaches to the same deterministic workflow id. Temporal's workflow execution timeout, not the broker window, is the terminal deadline. A reconciled processing execution is polled without rehydrating its original payload_ref or rerunning launcher preflight. If its cached flow is unknown, it takes this conservative non-terminal path so rollback can drain it without depending on the source artifact.

Enabling auto-split with batch ingress requires BROKER_KAFKA_MAX_POLL_INTERVAL_MS to be strictly greater than the split timeout in milliseconds. Batch ingress always publishes a result, so enabling auto-split there (or on a separately flagged result publisher) also requires BROKER_KAFKA_RESULT_REFS_ENABLED=true; a valid recomposed Daily result can exceed Kafka's hard request size even when every Temporal partition is safe. The default 900 second split poll window requires a Kafka max poll interval above 900000 ms; the existing 1800000 ms default satisfies it.

Kafka max request size is intentionally higher than the inline target. CIS keeps normal completed results inline up to BROKER_KAFKA_INLINE_RESULT_MAX_BYTES; when BROKER_KAFKA_RESULT_REFS_ENABLED=true, larger completed results are compressed to object storage and Kafka carries result_ref. Keep result refs disabled until all consumers support FlowResultEnvelope.result_ref.

The signed reference fields, directional key rotation, N/N-1 rollout, and fail-closed storage ordering are documented in ARTIFACT_REFERENCE_CONTRACT.md. FLOW_ARTIFACT_RETENTION_SECONDS must be greater than FLOW_ARTIFACT_REF_MAX_TTL_SECONDS + FLOW_ARTIFACT_REF_CLOCK_SKEW_SECONDS.

FLOW_INTENT_GUARD_MODE accepts off, observe, or enforce. observe is a pre-emission configuration/telemetry stage; CP must not emit v3 until CIS is in enforce. Every v3 mismatch is fail-closed in all modes and returns idempotency_intent_conflict, so a misordered flag cannot reveal the old result. Any non-off mode requires FLOW_INTENT_CP_TO_CIS_HMAC_KEYS, a JSON object of key ID to secret (minimum 32 bytes). Keep current and previous keys through the full Kafka, Temporal, and late-result retention window. FLOW_INTENT_LEGACY_RUN_POLICY=allow temporarily permits a v3 redelivery to join a pre-v3 Temporal history without a fingerprint memo; switch to conflict only after those histories drain.

CONTENT_TRUST_MODE is the default for direct/local runs and accepts off, observe, or enforce. A signed v5 broker request overrides that local default with settings.content_trust_policy.mode; the broker pins the same policy in the outer envelope and rejects a mismatch through the v5 intent binding. CONTENT_TRUST_EVALUATOR_ENABLED=false performs no extra model request. When enabled, CIS makes one post-output structured evaluation using CONTENT_TRUST_EVALUATOR_MODEL, falling back to FLOW_SHADOW_MODEL; it never runs a shadow evaluator. CONTENT_TRUST_EVALUATOR_MAX_INPUT_CHARS is a hard bound over the typed output plus derived conversation documents. Oversize, invalid schema, or provider outage is typed as unavailable/invalid and becomes review under an enforce request. No regex result is treated as authoritative.

Production-minimal evidence has a separate fixed 24-hour capability and a minimum 48-hour physical lifecycle. Startup rejects evidence enablement unless FLOW_RESULT_EVIDENCE_STORAGE_POLICY_VERIFIED=true and signed v2 emission is ready. FLOW_ARTIFACT_SERVER_SIDE_ENCRYPTION is mandatory and accepts AES256 or aws:kms; KMS requires a key id. A transient upload is attempted at most three times. While the result is younger than the 900-second grace CIS leaves the Kafka offset uncommitted for idempotent redelivery; after grace it publishes the minimal projection with evidence.status=unavailable, never full debug inline. The external lifecycle, bucket policy and encryption check remain a deployment prerequisite rather than an in-process deletion scheduler.

FLOW_ARTIFACT_MAX_BYTES caps the compressed object body that CIS will write or read. FLOW_ARTIFACT_MAX_DECODED_BYTES caps the decompressed JSON payload before parsing to protect broker workers from malformed or unexpectedly large artifacts. Hydrated flow_run_payload artifacts use that same hard decoded-artifact cap. After validation and normalization, the launcher removes repeated runtime-only identity metadata and applies TEMPORAL_WORKFLOW_INPUT_SAFE_BYTES to the actual compacted workflow input. A valid artifact is not DLQed only because its raw JSON was larger than the compacted Temporal input.

The same private artifact bucket is required by split workflows. Scoped split keys are content-addressed without a wall-clock date, so the same Activity input returns the same ref across midnight; object age still drives the prefix lifecycle. Enabling REPORT_TEMPORAL_AUTOSPLIT_ENABLED fails startup unless WAHA_S3_ENDPOINT, WAHA_S3_ACCESS_KEY_ID, WAHA_S3_SECRET_ACCESS_KEY and FLOW_ARTIFACTS_BUCKET are all non-empty. This presence check does not replace the operational bucket, permission or lifecycle preflight. A batch/result-publishing worker cannot enable auto-split while BROKER_KAFKA_RESULT_REFS_ENABLED=false.

Oversized media_processed results can be externalized as sanitized derived artifacts. Their release depends on the WAHA object-store lifecycle rule for the flow-artifacts/media_processed/ prefix (7-day expiration) being deployed at the same time.

Media

MEDIA_DOWNLOAD_TIMEOUT_SECONDS=30
AUDIO_PROBE_TIMEOUT_SECONDS=10
AUDIO_TRANSCODE_TIMEOUT_SECONDS=120
GROQ_AUDIO_UPLOAD_MAX_BYTES=24000000
MEDIA_MAX_AUDIO_BYTES=40000000
MEDIA_MAX_IMAGE_BYTES=15000000
MEDIA_MAX_IN_MEMORY_BYTES=16000000
MEDIA_MAX_DOCUMENT_BYTES=64000000
MEDIA_MAX_VIDEO_BYTES=256000000
LINK_CACHE_ENABLED=true
LINK_CACHE_TTL_SECONDS=3600
LINK_CACHE_NEGATIVE_TTL_SECONDS=300
LINK_CACHE_MAX_ENTRIES=2048
TMP_DIR=/tmp/wardian
VIDEO_PROMPT_PATH=prompts/video.md

MEDIA_MAX_IMAGE_BYTES applies to image-like source files, including GIF and sticker inputs that may later be converted to video. MEDIA_MAX_VIDEO_BYTES applies only to source videos. This keeps animated image decoding and local frame expansion within the image-worker memory envelope.

Audio, document and video source downloads stream directly to a bounded file under TMP_DIR. MEDIA_DOWNLOAD_TIMEOUT_SECONDS is the total budget for DNS, redirects and the complete body, while the matching MEDIA_MAX_*_BYTES limit is enforced again for every decoded chunk even when Content-Length is absent or incorrect. A trusted expected SHA-256 is computed and checked during the same write; it is never logged. The file exists only inside the downloader context and is removed on success, failure, timeout or cancellation. Image-like inputs remain in memory behind the 15 MB limit and link previews behind their 1 MB limit. MEDIA_MAX_IN_MEMORY_BYTES is the explicit ceiling for every in-memory safe HTTP consumer; startup fails if either configured download budget exceeds it. Raising MEDIA_MAX_IMAGE_BYTES therefore requires an intentional matching memory-ceiling change.

Capacity planning must include temporary disk rather than RAM: concurrency 4 at the 256 MB video limit can hold 1.024 GB of source files, before derived audio/frames. Keep at least 3 GiB free per video-worker host. Global per-tenant disk quotas and backpressure remain tracked with WAR-108.

The reproducible downloader benchmark is:

.venv/bin/python scripts/benchmark_safe_http_streaming.py --mode both

It runs 40/64/256 MB at concurrency 1/2/4, with one warm-up and three measured samples per case. On the implementation host, the critical 256 MB/concurrency 4 file-backed case used at most 8.9 MiB incremental RSS, left zero files and reached 178.4 MB/s median throughput. The equivalent former bytes-then-copy baseline used 745-1007 MiB incremental RSS and reached 172.7 MB/s median throughput. Results are host-specific; the RSS, throughput and cleanup gates must be rechecked in the target container before rollout.

GIF or sticker messages whose declared MIME type is video/* are processed as source videos while preserving their logical media type in the derived output. This covers WhatsApp GIF playback payloads stored as MP4 containers.

AUDIO_PROBE_TIMEOUT_SECONDS bounds the local ffprobe preflight that runs once before every Groq transcription. A valid file with no audio stream returns the stable degraded code no_audio_track without calling Groq or retrying the Temporal activity. Probe infrastructure failures (timeout, missing binary, non-zero exit, or invalid JSON) raise audio_probe_failed:* so the existing Temporal retry policy still applies. The same probe supplies duration for usage and cost attribution; CIS does not run a second duration probe.

The private tutor-guidance transcription tool is configured only on the API process:

AUDIO_TRANSCRIPTION_TOOL_BEARER_TOKEN=
AUDIO_TRANSCRIPTION_TOOL_MAX_REQUEST_BYTES=16384
AUDIO_TRANSCRIPTION_TOOL_MAX_DURATION_SECONDS=120
AUDIO_TRANSCRIPTION_TOOL_MAX_CONCURRENT_REQUESTS=4
AUDIO_TRANSCRIPTION_TOOL_TIMEOUT_SECONDS=55

An empty dedicated token disables the endpoint with fail-closed authentication. The tool accepts only object references in WAHA_S3_BUCKET; it does not accept URLs or inline bytes. AUDIO_TRANSCRIPTION_TOOL_TIMEOUT_SECONDS bounds the Groq request and configures each S3 network attempt; S3 uses standard mode with at most two attempts. Request, object size, probed duration and concurrency limits protect the API independently from report activities.

The API removes orphaned media in TMP_DIR on startup. For this private tool, an audio stream whose duration cannot be verified is rejected before any model call; the optional duration declared by CP is never trusted as the hard-limit source.

LINK_CACHE_* controls the in-memory link preview cache inside each text-processing worker process. Successful link metadata and unsupported content-type responses use LINK_CACHE_TTL_SECONDS; HTTP 429 failures use the shorter negative TTL so repeated links do not hammer the same origin during one report burst. The cache never logs raw URLs and is not shared across worker processes or restarts.

Reusable media insights

MEDIA_INSIGHT_MODE=off
MEDIA_INSIGHT_MODEL_EPOCH=2026-07-21
MEDIA_INSIGHT_BATCH_SIZE=50
MEDIA_INSIGHT_REQUEST_TIMEOUT_SECONDS=10
OPENROUTER_VISION_MODEL=meta-llama/llama-4-scout
OPENROUTER_PRESET=main-preset
OPENROUTER_REQUIRE_PARAMETERS=true
GROQ_AUDIO_DEFAULT_MODEL=whisper-large-v3-turbo
GROQ_AUDIO_FALLBACK_MODEL=whisper-large-v3
OPENROUTER_VISION_DETAIL=low
VIDEO_FRAME_COUNT=8
GIF_FRAME_MAX=8
GIF_MIN_FRAME_COUNT=3
GIF_SECONDS_PER_FRAME=3.0
GIF_DEFAULT_FRAME_DURATION_MS=100
MEDIA_MAX_EMBEDDED_IMAGES=5
MEDIA_MAX_EMBEDDED_IMAGE_BYTES=8000000
CONTROL_PLANE_BASE_URL=
CONTROL_PLANE_API_KEY=

GIF_FRAME_MAX must be positive. For animated sticker/GIF processing, GIF_FRAME_MAX, GIF_DEFAULT_FRAME_DURATION_MS, GIF_MIN_FRAME_COUNT, and GIF_SECONDS_PER_FRAME are part of the processor signature because they change animated-media sampling sent to Gemini or the legacy vision backend.

MEDIA_INSIGHT_MODE is a coordinated CP/CIS rollout switch:

  • off: legacy media processing only;
  • observe: CP records tenant-scoped fingerprints and duplicate opportunity
  • metadata, while CIS still processes media normally;

  • write: shared claims and artifact writes are enabled, but ready artifacts
  • are not read; the current report keeps the newly derived result inline;

  • read: ready artifacts are reused and hydrated only inside report
  • Activities.

write and read require the Control Plane URL and API key. Keep both CONTROL_PLANE_* values configured through mode rollbacks and for at least the maximum in-flight workflow duration after the last read worker is replaced. An off or observe worker may still receive a media_insight_ref emitted by an older in-flight workflow; without CP configuration that Activity fails explicitly and retries instead of silently degrading reusable evidence. Once no in-flight refs remain, off and observe do not require CP configuration.

CIS does not own or persist cache state: every lookup, lease, source URL, completion, and hydration goes to CP. Multiple CIS replicas therefore share Postgres lease and fencing state plus CP-owned encrypted object storage. Local worker files remain temporary download scratch under /tmp/wardian and are deleted by the existing media processors.

The versioned wardian.media-insight.v1 processor signature includes media type, normalized MIME, processor/profile versions, provider/model and fallback, MEDIA_INSIGHT_MODEL_EPOCH, prompt and settings hashes, taxonomy/toolchain, privacy policy, and output schema. CP rejects mismatched signatures. Change the model epoch whenever an operational change should invalidate all prior derived outputs even if the other descriptor fields are unchanged. Video descriptors follow the active backend: Gemini hashes the video prompt, while explicit legacy processing hashes the vision prompt and identifies the vision/Groq pipeline so prompt changes invalidate reusable legacy insights. OPENROUTER_VISION_DETAIL, OPENROUTER_PRESET, OPENROUTER_REQUIRE_PARAMETERS, OPENROUTER_VIDEO_MODEL, VIDEO_PROCESSOR_BACKEND, OPENROUTER_VIDEO_MEDIA_RESOLUTION, OPENROUTER_VIDEO_INLINE_MAX_BYTES, VIDEO_MAX_OUTPUT_TOKENS, ANIMATED_MEDIA_ENCODE_FPS, VIDEO_FRAME_COUNT, all four GIF_* sampling settings, MEDIA_MAX_EMBEDDED_IMAGES, and MEDIA_MAX_EMBEDDED_IMAGE_BYTES are explicit signature inputs where they affect the selected processor. Changing one automatically invalidates the affected image/video/document output and falls back safely if the coordinated CP setting has not rolled out yet. Requests with non-empty media prompt overrides or explicit per-run settings.video_processing overrides bypass reusable insights so an override can neither publish nor consume an artifact under the default signature. Every media worker also recomputes the descriptor from its own model, prompt, settings, and policy configuration before accepting a claim. During a rolling deploy, an N-1 replica therefore releases a claim whose CP signature belongs to N and uses the legacy URL instead of publishing an output under the wrong key.

In read, Temporal carries only media_insight_ref after a hit or successful completion. The resolver preflights hit hydration and reclaims an expired, missing, or corrupt CP artifact before scheduling media. If the preflight or repair call is unavailable, that hit bypasses reuse while the original URL is still available and the legacy media worker runs. A late hydration miss degrades only that media item instead of failing the report. Once a workflow carries a ref, the text worker hydrates its tenant-authorized semantic projection immediately before prompt construction even if the process-level mode changed during a rolling deploy; in-flight read histories never depend on the worker's current launch mode. Source URLs are minted only for an active fenced claim. The expected SHA-256 lives in Activity-private trusted state that cannot be populated by caller metadata, and downloaded bytes are verified before any provider call. The original URL is retained only as Activity-private fallback/type-hint state, so opaque signed document URLs cannot erase a reliable .pdf or .docx hint. URLs, raw responses, prompts, model traces, run IDs, and message IDs are excluded from the reusable projection. Degraded media results release the claim and are never published as reusable insights. If a fenced source fails before processing or its digest mismatches, CIS releases the claim and retries the legacy URL when the CP envelope supplied one. A pending-claim timeout or malformed CP lookup response also bypasses reuse and runs the legacy media path. Write mode never substitutes a hit reference into the current report; it keeps processing inline. Resolver Activities receive only the versioned insight contract plus message ID, media type, and MIME; captions, provider metadata, and media URLs stay out of lookup payloads. A cache hit is resolved before applying the legacy media worker payload bound. On a miss, CIS compacts optional message metadata before scheduling the worker, bounds the derived result before CP completion, and stores only that bounded semantic projection. Reusable audio retains the final transcription and necessary technical metadata but drops redundant per-segment transcript copies before persistence and hydration. Resolver Activity failures and CP denied responses bypass reuse per media item and preserve the legacy media worker path; optional cache availability or authorization can never fail the full report or suppress otherwise available media.

Recipes

PUBLIC_RECIPE_IDS=smoke-test-default,initial-ingestion-report-text-only,initial-ingestion-report-text-audio,initial-ingestion-report-all-media,daily-summary-and-notifications-report-text-only,daily-summary-and-notifications-report-text-audio,daily-summary-and-notifications-report-all-media,interweek-report-default
RECIPES_DIR=config/recipes
TAXONOMY_PATH=config/taxonomies/risk-v1.yaml
VISION_PROMPT_PATH=prompts/vision.md

FlowRunSettings.media_policy can override recipe media behavior for one run without creating a new recipe. The canonical keys are audio, image, sticker, gif, video, document, and link, and values are booleans. Control Plane repair runs map their modes onto this existing contract:

  • text-only: set every canonical key to false;
  • with-media: set every canonical key to true;
  • omit degraded media: retain the selected recipe policy and set only the
  • diagnosed media types to false.

Request overrides are applied both while compacting the Temporal input and inside the workflow. Disabled media URLs are removed before the workflow is started and no activity is scheduled for those types. This is a per-type policy; it does not select individual message ids and it does not alter the external flow or diagnostic contract.

Dev Tools

With DEV_MODE=true, /dev exposes:

  • /dev/flows
  • /dev/playground/run
  • /dev/whatsapp-dump-analyzer
  • /dev/model-io-lab
  • /dev/ingestion-exporter
  • /dev/media-prompt-lab
  • /dev/cp-prompt-lab when ENABLE_CIS_PROMPT_LAB=true

CP Prompt Lab also requires:

ENABLE_CIS_PROMPT_LAB=false
CONTROL_PLANE_BASE_URL=
CONTROL_PLANE_API_KEY=
CIS_PROMPT_LAB_RESULT_POLL_TIMEOUT_SECONDS=20
CIS_PROMPT_LAB_RESULT_POLL_INTERVAL_SECONDS=0.5
CIS_PROMPT_LAB_WORKFLOW_EXECUTION_TIMEOUT_SECONDS=14400
CIS_PROMPT_LAB_MODEL_RUN_MAX_COUNT=10
CIS_PROMPT_LAB_STORAGE_BACKEND=s3
CIS_PROMPT_LAB_STORAGE_BUCKET=cp-prompt-lab
CIS_PROMPT_LAB_STORAGE_PREFIX=cp-prompt-lab

Both DEV_MODE=true and ENABLE_CIS_PROMPT_LAB=true are required. If either gate is false, every CP Prompt Lab UI/API route returns 404 and the Prompt Lab repository is not initialized. Compose keeps ENABLE_CIS_PROMPT_LAB=false. When Prompt Lab is enabled in a non-local environment and CIS_PROMPT_LAB_STORAGE_BACKEND is omitted, CIS uses s3; if the bucket is also omitted, it uses cp-prompt-lab. This recovery default does not provision storage. Before enabling the feature, create that private bucket (or configure another dedicated bucket), grant the existing WAHA S3 credentials read, write, list, and delete access, and apply the deployment's encryption and deliberate retention policy. Do not inherit the short-lived Flow Artifact lifecycle: terminal Prompt Lab objects are append-only and remain durable until an operator-approved retention process removes them. Alpha compose sets the backend and bucket explicitly and initializes the private bucket. The S3 backend uses the existing WAHA_S3_ENDPOINT, WAHA_S3_ACCESS_KEY_ID, WAHA_S3_SECRET_ACCESS_KEY, WAHA_S3_REGION, and WAHA_S3_FORCE_PATH_STYLE connection settings, but CIS_PROMPT_LAB_STORAGE_BUCKET must name a dedicated private bucket. Do not reuse FLOW_ARTIFACTS_BUCKET or WAHA_S3_BUCKET: startup fails closed when the normalized Prompt Lab bucket name matches either shared bucket. Keep the Prompt Lab prefix outside every flow-artifacts/ namespace.

filesystem is an explicit local/test backend only. Select it with CIS_PROMPT_LAB_STORAGE_BACKEND=filesystem and optionally set CIS_PROMPT_LAB_STORAGE_ROOT; it is rejected when APP_ENV is not a local/development/test value. Directories use mode 0700, files use 0600, and create-only JSON publication uses a same-directory temporary file plus an atomic link after file sync. Durable shared environments must use s3. When the Prompt Lab snapshot launcher is temporal, startup also requires WAHA_S3_ENDPOINT, WAHA_S3_ACCESS_KEY_ID, WAHA_S3_SECRET_ACCESS_KEY, and FLOW_ARTIFACTS_BUCKET. That transport store is required even when the durable Prompt Lab repository uses filesystem, because an oversized prepared result may be returned through a temporary Flow Artifact object. The same private preparation-context object also pins the normalized request. When the request is larger than the Temporal workflow-input budget, only its bounded media projection and validated context reference enter the workflow. The full request is hydrated from that object by the final text activity. When the projected request plus derived media would exceed the final Temporal activity-input budget, the workflow again sends the context reference instead of duplicating the request inline.

Under FLOW_ARTIFACT_REF_POLICY_MODE=require_v2, a tenantless preparation signs those temporary objects with the reserved cis-internal-tenantless artifact scope. That scope is carried separately from the application tenant and is never sent to media processing.

The tool proxies CP internal endpoints server-side, publishes edited envelopes to Kafka with x-cis-prompt-lab-mode=preview-only, and reads CONTENT_INTELLIGENCE_PROMPT_LAB_RESULTS_TOPIC. The preview result topic carries conversation text and prompts, so it is dev-only and should use short retention.

The Initial Report workbench prepares snapshots with the dedicated ProcessPromptLabSnapshot Temporal workflow. It always forces initial-ingestion-report-all-media and enables audio, image, sticker, gif, video, document, and link. The workflow reuses production normalization, media processing, and conversation building, then stops before the Initial Report model. A valid prepared result records report_model_calls=0.

Terminal Prompt Lab storage is append-only. Only explicitly temporary preparation-recovery, preparation-recovery-input, and run-finalizations objects are deletable. S3 writes use create-only preconditions; an identical retry is accepted and a different value at the same key fails with prompt_lab_snapshot_write_conflict. The configured prefix contains:

preparations/{preparation_id}/events/{sequence}.json
preparation-recovery/{preparation_id}.json  # deleted after terminal publication and cleanup
preparation-recovery-input/{preparation_id}.json  # deleted after terminal publication
snapshot-summaries/{reverse_created_at}-{snapshot_id}.json
snapshot-source-index/{source_key_hash}/{reverse_created_at}-{snapshot_id}.json
snapshots/{snapshot_id}/manifest.json
snapshots/{snapshot_id}/conversation-documents.json
snapshots/{snapshot_id}/media-outputs.json
snapshots/{snapshot_id}/prompt-version-sequence/{version}.json
snapshots/{snapshot_id}/prompt-versions/{prompt_version_id}.json
snapshots/{snapshot_id}/request-previews/{prompt_version_id}/{request_fingerprint}.json
snapshots/{snapshot_id}/run-attempts/{run_id}/pending.json
snapshots/{snapshot_id}/runs/{run_id}.json
run-index/{run_id}.json
run-finalizations/{run_id}.json  # deleted after terminal run publication
bootstrap/{batch_id}/manifest.json
bootstrap/{batch_id}/results/{monitoring_id}.json
bootstrap/source-claims/{source_key_hash}/attempts/{attempt_id}.json
bootstrap/source-claims/{source_key_hash}/winner.json
bootstrap/source-claims/{source_key_hash}/start.json

These objects may contain conversation text, derived media text, prompts, model responses, and report output. The bucket must use the deployment's private access controls and encryption policy. Prompt Lab never persists raw media bytes, authorization headers, cookies, API keys, or bearer tokens. A private recovery-input object may temporarily retain the normalized preparation request, including expiring media URLs, until a terminal snapshot is durable; it is then deleted. Repository serialization also rejects data URIs, file:// URIs in any field, and structured local path references: absolute POSIX, Windows, or UNC values under path/root/directory keys. Arbitrary slash text and web route strings remain valid in ordinary content and route fields.

The snapshot list reads only the privacy-minimized snapshot-summaries index and exposes bounded cursor pagination. Bootstrap reuse reads the source-scoped snapshot-source-index prefix instead of recursively scanning snapshot artifacts. Run and PDF lookup reads the direct run-index pointer instead of scanning every snapshot. Full manifests and conversation content remain behind the snapshot detail endpoint. Prompt version numbers are claimed with create-only sequence reservations so concurrent saves cannot allocate the same version. The complete run index is persisted before its pending attempt claim and before the provider is invoked. A retry can resume the index-to-claim gap, while an existing claim remains fail-closed against duplicate provider cost. Every run request must supply a client-generated run_id; the terminal run object reuses that identity, and retries must reuse the same ID. After the provider returns, the service writes a temporary finalization journal before the terminal run object; startup reconciliation or an exact-ID retry can complete publication without a second provider request. The browser keeps at most 20 non-sensitive request-identity tuples in tab-scoped sessionStorage so a reload can reuse an ambiguous run ID. It never stores the API token, prompt text, model response, or conversation content there, and removes an entry after observing that exact run as terminal.

Before launching a Temporal preparation, the service persists a non-sensitive immutable recovery descriptor and then its separately deletable private recovery-input object. This ordering cannot strand sensitive input when descriptor publication fails. On startup it starts or attaches to the deterministic workflow ID and finalizes the snapshot. Prompt content, model parameters, and a versioned user-prompt renderer are pinned in the workflow input so activity retries fail closed instead of drifting across worker deployments. Client waits use CIS_PROMPT_LAB_RESULT_POLL_TIMEOUT_SECONDS; the durable workflow continues after a request timeout, while CIS_PROMPT_LAB_WORKFLOW_EXECUTION_TIMEOUT_SECONDS bounds the durable execution and defaults to four hours so fixed windows with several thousand media items can finish without changing the timeout used by normal report workflows. Temporary recovery inputs and prepared-result artifacts are deleted only after durable finalization, with bounded cleanup behavior.

After preparation, editing prompts, changing models, previewing requests, and running model variants never start Temporal or the conversation builder again. Every prompt must be saved as an immutable version. The request checkpoint shows the exact secret-free application payload passed to the OpenRouter client and requires confirmation of its fingerprint before execution. Completed Initial Report runs can be rendered with the same wardian.monitoring-initial.v1 renderer used by Core. The gated Prompt Lab PDF route composes a separate, visibly labeled Prompt Lab Trace - DEV ONLY appendix; the production PDF input and renderer contract cannot enable it.

Manual 30-day snapshot bootstrap

The bootstrap is manual and one-time; no scheduler is installed. Prompt Lab requires PRIVACY_PROFILE=debug_plaintext; startup rejects the durable feature under protected. Confirm the two feature gates, dedicated S3 bucket, reachable Control Plane, Temporal, and all media workers before starting. Capture one UTC end timestamp and use it for the dry run:

WINDOW_END="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
PROMPT_LAB_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/wardian/cp-prompt-lab"
install -d -m 700 "$PROMPT_LAB_STATE_DIR"
PROMPT_LAB_MANIFEST="$PROMPT_LAB_STATE_DIR/bootstrap-$WINDOW_END.json"
.venv/bin/python scripts/cp-prompt-lab-bootstrap.py \
  --window-end "$WINDOW_END" \
  --dry-run \
  --output "$PROMPT_LAB_MANIFEST"

The dry run traverses every snapshot-sources page and calls no media worker or model. It writes no Prompt Lab repository object; --output creates a local manifest with mode 0600. Review its fixed window_start, window_end, source revisions, and enumerated/planned counts before execution.

Execute only from that exact reviewed manifest:

.venv/bin/python scripts/cp-prompt-lab-bootstrap.py \
  --execute-from "$PROMPT_LAB_MANIFEST"

Execution re-enumerates and rejects a changed window or source revision. Each eligible monitoring independently requests envelope-preview, prepares one all-media pre-model snapshot, and stores an immutable terminal result. Verify that created + reused + skipped + degraded + failed == enumerated; no item may remain queued or processing. Preserve the first manifest and failure evidence; do not automatically recompute the window or rerun failed sources.

A client polling timeout does not create a failed result: execution waits for the claimed snapshot to become terminal. If the bounded claim wait also expires, the command exits without writing that source result or the completed batch manifest, so the same reviewed manifest can resume it deterministically.

Rollback

Stop new bootstrap submissions, set ENABLE_CIS_PROMPT_LAB=false, and restart the API. This removes the dev routes and prevents repository initialization; it does not alter production /analyze, broker, media, or PDF contracts. Before rolling workers back to a build that does not register ProcessPromptLabSnapshot, let active Prompt Lab workflows drain. Keep the dedicated bucket and reviewed dry-run manifest: stored objects are immutable evidence and must not be deleted or rewritten during rollback. After restoring the feature, resume an interrupted batch with the same --execute-from manifest so deterministic preparation IDs attach to existing Temporal work.

/dev/dumps/config also includes dev_runtime metadata with the active worktree path, git branch/commit, dirty flag, and short fingerprints for key dev assets. Dev pages show that metadata in the runtime badge so stale containers or wrong worktrees are visible.

Use ./scripts/dev-assert-active-worktree.sh before visual verification if a Docker stack is already running. It fails when the API container mounted at /app is not the current checkout.

Compose

flowchart LR
  A["api"] --> B["temporal"]
  C["orchestration worker"] --> B
  D["media/text workers"] --> B
  B --> E["postgresql for Temporal"]
  F["broker-worker"] --> B
  F --> G["Kafka"]

Timestamp trust for raw WAHA input is fail-closed and has no CIS-side bypass: missing/malformed clocks must be reconciled by the durable producer. CP owns the shadow/enforce/reconciliation flags and emits the optional side-contract.

Monitoring-close cancellation

The lane is off by default. Enable it only after the orchestration worker containing CanceledFlowFence is deployed. While disabled, the Compose worker remains idle without consuming commands so restart: unless-stopped does not create an exit/restart loop. The cancellation worker never starts Prompt Lab reconciliation; API/broker ownership of that loop remains unchanged even when dev Prompt Lab flags are enabled. Cancellation requires WORKFLOW_BACKEND=temporal; startup rejects the process-local local backend because a separate cancellation worker cannot reach its in-memory tasks.

  • CIS_FLOW_CANCELLATION_ENABLED
  • CIS_FLOW_CANCELLATION_COMMAND_TOPIC
  • CIS_FLOW_CANCELLATION_RECEIPT_TOPIC
  • CIS_FLOW_CANCELLATION_CONSUMER_GROUP
  • CIS_FLOW_CANCELLATION_CP_TO_CIS_HMAC_KEYS: JSON verification keyring for commands.
  • CIS_FLOW_CANCELLATION_CIS_TO_CP_HMAC_KEYS: JSON signing keyring for receipts.
  • CIS_FLOW_CANCELLATION_CIS_TO_CP_ACTIVE_KEY_ID
  • CIS_FLOW_CANCELLATION_CLOCK_SKEW_SECONDS
  • CIS_FLOW_CANCELLATION_FENCE_TIMEOUT_SECONDS
  • CIS_FLOW_CANCELLATION_WORKFLOW_WAIT_SECONDS: bounded wait for Temporal to reach a terminal state before cleanup and receipt; timeout leaves the Kafka command uncommitted for retry. When cancellation is enabled it must be at least the report reducer heartbeat timeout (10 seconds; default wait 30 seconds).
  • CONTROL_PLANE_BASE_URL and CONTROL_PLANE_API_KEY: required for fail-closed broker fence checks.

Directional keys must be at least 32 bytes. Rotation adds the new verification key on both sides before switching the active signing key; retain the previous verifier until all old messages expire.