<p>On 2026-06-26 the scheduled daily WSJF triage instruction fired on time, called llm-connect successfully, and produced a long ranked recommendation list — but the JSON broke at char 5268 (~rank 8–9 of ~16), failing schema validation. Because the report was validated and consumed as a single monolithic JSON document, one malformed delimiter discarded the <strong>entire</strong> run, including the 7 perfectly good recommendations the model had already emitted. The scheduling and runtime layers were healthy; the failure was entirely at the seam where free-form model output meets a strict consumer.</p>
<p>This is not a one-off bug, it is a recurring class. activity-core has a <strong>trust boundary</strong> wherever generative or human-authored output meets strict deterministic consumers: the JSON Schema validator, the task emitter, and any classic compute pipeline downstream. The producers on the other side of that boundary — <strong>LLMs, agents, and humans</strong> — are all <em>untrusted producers</em>. Their output may be:</p>
<ul><li><strong>erroneous</strong> — hallucination, truncation at a token limit, drift, type slips, typos, a missing delimiter; or</li><li><strong>malicious</strong> — prompt injection, crafted payloads, or oversized / deeply-nested structures intended to exhaust or confuse the consumer.</li></ul>
<p>The pre-existing design treated producer output optimistically: parse the whole document, validate the whole document, and on any failure discard the whole document (preserving only a bounded diagnostic preview). That gives <strong>zero error locality</strong> — the blast radius of any single defect is the entire activation.</p>
</section>
<sectionid="decision"><h2>Decision</h2>
<p>Treat the producer→consumer seam as an explicit, adversarial <strong>trust boundary</strong>, and place guardrails plus error-correction tooling <em>at that boundary</em> rather than letting raw producer output flow into deterministic consumers.</p>
<h3>Two non-fail-fast postures</h3>
<p>When hard-failing on a problem is undesirable, there are two sound strategies, and they <strong>compose</strong>:</p>
<ul><li><strong>A) Trust but handle exceptions</strong> (optimistic / reactive). Consume the output as-is; on exception, catch → repair → retry → or quarantine. Cheap on the happy path; blast radius depends entirely on how granular the catch is. Best when failures are rare and locally recoverable. Risk: failures surface late, possibly after partial side effects.</li><li><strong>B) Verify and mitigate</strong> (defensive / proactive). Validate, sanitize, clamp, and normalize the output to a known-good shape <em>before</em> it enters the pipeline — drop bad items, coerce types, bound sizes/depth, allow-list references — so the consumer only ever sees clean input. Higher upfront cost, smaller blast radius, no partial side effects. Best when failures are common or consequences are high.</li></ul>
<h3>Governing principles</h3>
<ol><li><strong>Push verification to the boundary; keep the interior strict.</strong> Apply posture <strong>B</strong> at the producer→consumer boundary; keep posture <strong>A</strong> for residual exceptions inside the verified core. Never relax the interior schema to absorb producer sloppiness.</li><li><strong>Make error locality match the unit of work.</strong> One bad recommendation must cost one recommendation, not the whole report. Structuring the payload so each item is independently parseable and validatable is the highest-leverage change.</li><li><strong>Quarantine, never silently drop.</strong> Invalid units are preserved as bounded, provenance-tagged artifacts (<code>index</code>, <code>error</code>, <code>raw</code> snippet, <code>reason</code>) so they can be debugged or replayed. Degraded-but-usable is reported distinctly from total loss.</li><li><strong>Both human and agent input get the same rigor.</strong> Guardrails are producer-agnostic: the same count / length / depth caps and reference allow-lists apply whether the producer is an LLM, an agent, or a human.</li></ol>
<h3>What this means concretely in activity-core</h3>
<p>Implemented in <code>src/activity_core/rules/executor.py</code>:</p>
<ul><li><strong>Strict-structure-only schema.</strong> The daily-triage output schema is strict on per-item <em>structure</em> (<code>required [rank, candidate, action, why]</code>, typed <code>wsjf</code>) and carries <code>maxItems</code> as a producer <em>hint</em> — never as a hard whole-document reject, which would reproduce the very blast-radius failure (ACT-ADR-002 governs the schema format; <code>schemas/daily-triage-report.json</code>).</li><li><strong>Item-granular recovery (posture B).</strong> When whole-document parse + one retry fail, <code>_resilient_report</code> recovers individually-parseable recommendation objects via a brace/quote-aware scanner (<code>_extract_object_spans</code>) that works for both pretty-printed and NDJSON output, attempts a best-effort <code>_try_repair</code> on a truncated tail, validates each recovered object against the item schema, and keeps the valid ones. Survivors are emitted with <code>output_validated=true</code>, <code>partial=true</code>, and <code>review_advisory=true</code> (<code>review_gate_applied=false</code>).</li><li><strong>Producer guardrails (<code>_partition_items</code>, applied on both the recovery and the happy path).</strong> Per recommendation: structural type → schema → structural caps (<code>_MAX_DEPTH</code>, <code>_MAX_STRING_LEN</code>) → reference allow-list → count cap (top-N by <code>maxItems</code>). The first failing check quarantines the item with provenance and a <code>reason</code> (<code>malformed</code> / <code>schema</code> / <code>guardrail</code> / <code>allow_list</code> / <code>over_limit</code>).</li><li><strong>Reference allow-list.</strong> A recommendation whose <code>candidate</code> is not in the set of known ids is quarantined. The set is sourced from resolved context (<code>context["known_candidates"]</code>, via <code>_allow_list_from_context</code>); the check is inert until a context resolver populates it, so the capability ships now and activates with a one-line resolver change.</li></ul>
<ul><li>A single malformed or oversized item no longer discards an entire activation; the daily-triage run that failed on 2026-06-26 would now deliver its 7 valid recommendations and quarantine the broken tail.</li><li>Reports gain a <code>partial</code> / <code>quarantined_*</code> vocabulary; downstream report sinks and reviewers can distinguish degraded-but-usable from total loss.</li><li>Guardrail thresholds (<code>_MAX_DEPTH</code>, <code>_MAX_STRING_LEN</code>, <code>maxItems</code>, the allow-list) are policy knobs that will need tuning; they are intentionally conservative defaults, not a finished calibration.</li><li><strong>Known retention gap (follow-on):</strong><code>LLMConnectClient.complete()</code> still returns only <code>content</code>, discarding <code>finish_reason</code>/<code>usage</code>, and the total-loss artifact caps raw output below realistic break points. Capturing those signals so failures stay debuggable is tracked as a retention fix, not closed by this ADR.</li></ul>
<ul><li><strong>Hard-enforce <code>maxItems</code> in the validator.</strong> Rejected: a hard reject of an over-count document reproduces the whole-document blast radius. Mitigation (keep top-N, quarantine the rest) is preferred.</li><li><strong>Relax the schema to accept anything.</strong> Rejected: violates principle 1; pushes malformed data into downstream consumers.</li><li><strong>Retry-until-valid only (pure posture A).</strong> Rejected as the sole strategy: the 2026-06-26 failure recurred across both the initial attempt and the retry, so retry alone does not bound the blast radius.</li></ul>
</section>
<sectionid="references"><h2>References</h2>
<ul><li>ACT-ADR-002 — markdown-as-definition format and output schema governance.</li><li>ACT-ADR-003 — Rule vs. Instruction model; the Instruction prompt-injection surface this boundary complements on the output side.</li><li><code>workplans/ACTIVITY-WP-0016-llm-output-robustness-trust-boundary.md</code> — the implementing workplan.</li></ul>