Documented things that go wrong and how to prevent them. Failure pattern claims are among the most valuable in any OOS because they encode lessons learned the hard way. Other organizations can learn from these without experiencing the failures themselves.
Gave analytics agent write access to campaigns. It optimized for wrong metrics.
Why: Lacked client context.
Failure mode: Decreased spend on strategic brand campaign.
Single shared state file became bottleneck and corruption source.
Why: Concurrent writes caused data races.
Failure mode: Two agents update simultaneously. One update lost.
When GPT generates content that fails fact-checking, log the failure type (fabricated claim, wrong client data, prohibited language, tone mismatch) and review monthly for patterns.
Why: After 3 months of logging, we found that 62% of GPT fact-check failures were fabricated social proof -- testimonials, case study numbers, and "as seen in" claims that didn't exist. Armed with this pattern, we added a pre-generation instruction to GPT: "Do not generate testimonials, case study results, or media mentions unless they appear verbatim in the client fact sheet." Fabricated social proof failures dropped 84% the next month.
Failure mode: Without categorized failure logging, the same error types recur. Generic "be more accurate" prompting doesn't target the specific failure mode.
When a cross-model handoff fails, the receiving model must reject the input and report the schema violation. It must never improvise with missing fields.
Why: The creative brief schema requires a "tone" field (professional, casual, urgent, educational). When a brief arrived without the tone field due to a schema version mismatch, GPT defaulted to "casual" -- its training default. The client was a law firm. The generated ad copy opened with "Hey there! Need a lawyer?" The account manager caught it, but the failure revealed that missing fields trigger model defaults rather than errors.
Failure mode: Missing schema fields are silently filled by model defaults. Defaults reflect training distribution, not client requirements. Casual tone is GPT's most common training context.
API failures on one platform (Meta or Google) must not block reporting on the other platform. Each platform's monitoring runs independently.
Why: An early architecture decision chained Meta and Google monitoring sequentially. When Meta's API went down for 4 hours on a Tuesday morning, Google Ads monitoring was also blocked because it waited for Meta to complete. We missed a Google Ads account that had exhausted its daily budget by 9 AM due to a bidding error. Cost: $1,100 in wasted spend before the media buyer checked manually at noon.
Failure mode: Sequential dependencies between independent data sources create cascading failures. One platform's outage blinds monitoring on unrelated platforms.
When an agent error impacts a franchisee, the corporate team is notified within 1 hour and the franchisee receives a personal call within 4 hours. Agent errors are not communicated via email or automated message.
Why: Franchisees pay franchise fees. An impersonal response to an agent-caused error signals that corporate doesn't take the relationship seriously. Two franchisees cited "lack of responsiveness to marketing errors" as a factor in non-renewal discussions.
Failure mode:
Any agent that produces a cross-location data leak (member PII visible outside its home location) triggers an immediate 24-hour audit of all cross-location reports produced in the prior 30 days.
Why: A single leak may indicate a systemic template error. Catching it early prevents regulatory exposure.
Failure mode: The C003 incident revealed that 3 other report templates had similar location-name-in-header issues. The 24-hour audit caught them before they were distributed.
Campaign launch failures (wrong creative, wrong audience, wrong location) require a root cause analysis within 48 hours. The analysis must identify whether the failure was data (wrong input), logic (wrong rule), or coordination (right data, wrong handoff).
Why: Without categorizing failures, fixes address symptoms. The C001 promo conflict was initially blamed on "bad creative" when the root cause was a missing coordination protocol between agents.
Failure mode:
If an agent error touches a client relationship, Marcus personally reaches out within 24 hours. The agent does not attempt to correct its own mistake in client-facing contexts.
Why: Automated error correction looks worse than the original error. A human apology preserves trust.
Failure mode: Timeline agent sent a project update with the wrong delivery date (confused two projects with similar names). Before Marcus could intervene, the agent sent a correction email. Client replied: "How many robots are running this?" Marcus lost 2 hours on damage control.
Agent errors involving incorrect client data (wrong name, wrong project, wrong dates) trigger an immediate audit of the data source, not just a correction of the output.
Why: Creative agencies juggle 8-12 active projects. Data cross-contamination between projects is the most dangerous failure mode.
Failure mode: Intake agent pulled revision notes from Project A into the brief for Project B because both clients had the same first name. The shot list was built on contaminated requirements. 4 hours of work scrapped.
Never auto-archive or auto-close a project. Only Marcus marks projects complete.
Why: Creative projects have long tails. A "delivered" video might come back for re-edits 3 months later.
Failure mode: Timeline agent auto-archived a project 30 days after final delivery. Client came back for a re-edit. All the organized revision history and shot notes were in the archive. Took 45 minutes to restore and re-orient.
The template modification incident (C001) was caused by the agent having write access to the template folder. The fix was simple: move templates to a locked folder with read-only permissions. The 22-hour cleanup was entirely preventable with proper access controls.
Why: The agent was trying to be helpful. It identified what it thought was an error and fixed it. In any other context, that initiative might be valued. In legal document assembly, unsupervised initiative is dangerous. Access controls are the only reliable safeguard against well-intentioned AI modifications.
Failure mode: Without folder-level access controls, any agent with file access can modify templates. The next modification might not be caught for months if it affects a rarely-used template (like the irrevocable life insurance trust). By then, dozens of documents could be affected.
Priya initially reviewed assembled documents by reading them end-to-end. This took 45 minutes per package and she still missed the template modification for 3 clients. The diff-check (C002) now catches structural changes automatically, and Priya focuses her 45-minute review on legal accuracy rather than template fidelity.
Why: Humans are poor at detecting subtle changes in dense legal text. Priya read the modified survivorship clause three times across three different trusts and did not notice because the change was plausible-sounding legal language. The agent did not make a typo. It made a legally coherent but incorrect modification.
Failure mode: Attorney reviews documents for obvious errors (misspellings, wrong names) but misses subtle legal modifications. Modified clause sounds correct to a quick read. Only surfaces during trust administration years later when the legal effect differs from the grantor's intent.
The cost of the template incident was not just the 22 hours. One of the three affected clients moved to a different attorney. That client was worth approximately $4,800 in lifetime value (annual reviews plus referrals). Total cost: $6,600 in non-billable time plus $4,800 in lost client value. $11,400 from a single agent error.
Why: In a solo practice generating $190K/year, $11,400 is 6% of annual revenue. The entire agent implementation was projected to save $42K/year (replacing the need for a second paralegal). One error consumed 27% of the first year's projected savings.
Failure mode: Cascading cost of a single template error in a solo practice: direct remediation cost + client churn + referral loss + reputation damage in a small legal community. The financial impact is disproportionate to the size of the error.
Any agent error involving student identity (wrong name, wrong data, wrong family) triggers a full audit of all recent outputs before any new communications are sent.
Why: The Jayden incident proved that a single identity error can cascade. If one record is wrong, others might be too.
Failure mode: After the Jayden name mixup, Keisha audited all 34 student records and found 2 additional minor data mismatches (wrong grade levels). If those had gone to parents, the trust damage would have been unrecoverable.
Never batch-send parent communications. Send one at a time with Keisha reviewing each individually.
Why: Batch sending multiplies errors. One mistake in a batch template affects every family.
Failure mode: Keisha tried batch-sending progress reports on the first biweekly cycle. The template had the wrong date header. All 34 families received reports dated for the wrong week. 12 parents replied asking about the date. Keisha spent 90 minutes sending correction notices.
When the code review agent cannot access a PR (private fork, permissions issue, deleted branch), it must report the failure, not skip the PR silently.
Why: A contributor opened a PR from a private fork. The code review agent couldn't access the fork's branch. It silently skipped the PR. The founder assumed "no review comments" meant the PR was clean. He merged it. The PR introduced a dependency with a known CVE. The code review agent would have flagged the dependency if it had been able to read the diff. Silent skip looked identical to clean review.
Failure mode: Access failures produce the same output as "nothing to report." The reviewer cannot distinguish between "reviewed and clean" and "not reviewed."
Linear task creation from agent triage requires the founder's approval. The triage agent drafts Linear tasks; it does not create them.
Why: The triage agent created 23 Linear tasks in its first week from GitHub issues and Slack messages. Seven were duplicates. Four were feature requests the founder had already decided against. Two were from the same user filing multiple reports about expected behavior. The founder spent 45 minutes cleaning up Linear -- longer than manual triage would have taken.
Failure mode: Automated task creation from unfiltered input fills the task system with noise. Cleanup takes longer than manual curation. The task system stops being trustworthy.
Agent output quality must be measured against the time the founder saves, not the volume of output produced.
Why: The code review agent produced reviews for 100% of PRs. Impressive. But 70% of PRs were the founder's own code -- code he'd just written and already knew the issues with. The "time saved" on self-authored PRs was near zero. The agent was most valuable on contributor PRs (30% of volume) where the founder hadn't seen the code. We scoped the agent to contributor PRs only and saved the founder 20 minutes/day by eliminating the noise of reviewing his own reviews.
Failure mode: Agents optimize for coverage instead of value. Running on every input (including inputs the human already has context for) creates review overhead that exceeds the review benefit.
When the solo founder is unavailable for 24+ hours (vacation, illness), agents must queue output and pause any time-sensitive actions rather than accumulate unreviewed decisions.
Why: The founder took a 3-day weekend without pausing agents. He returned to 47 triaged issues, 12 code reviews, and 3 draft changelogs. The backlog took 2.5 hours to process. Worse, 2 P1 issues had been sitting in triage for 72 hours with users waiting for responses. The agents correctly triaged them as urgent but had no mechanism to escalate when the human wasn't responding. Now agents pause after 24 hours of no human interaction and send a single "review queue paused -- items waiting" notification.
Failure mode: Agents continue producing output when the solo human is unavailable. Backlog accumulates. Time-sensitive items age without escalation. The founder returns to a wall of decisions that should have been made 2 days ago.
When an agent produces an output that contains information from the wrong engagement, treat it as a critical incident. Full audit: which agent, which data, how it crossed the boundary, and architectural fix. Not just a correction.
Why: Information barrier breaches in consulting are existential. A pattern of near-misses means the architecture is fundamentally flawed, not that you got unlucky.
Failure mode: After the Haldane/Orion incident, we initially just "corrected the document." The same class of leak happened again 3 weeks later with different clients. Only after treating it as a structural failure and redesigning the agent architecture (splitting Lens and Recon, implementing sequential processing) did the problem stop.
Agent-generated content that sounds authoritative but is fabricated (hallucinated frameworks, invented statistics, nonexistent case studies) must be caught before client delivery. Every deliverable draft must be checked against Vault's source library.
Why: Consultants trust agent output more as they get comfortable. The fabrication rate is low enough to create a false sense of reliability but high enough to cause real damage when it slips through.
Failure mode: Beyond the "4D Transformation Framework" incident (C005), Lens cited a "McKinsey 2025 Industry Report" that does not exist in a market analysis. The consultant included it in the deliverable. The client's team tried to find the report and couldn't. Credibility damaged.
When an agent triggers a member-facing action that results in a complaint, the entire outreach queue for that agent pauses until Jamie reviews and clears it.
Why: One bad message might be a fluke. Two bad messages in a row is a systemic problem. Pausing prevents compounding damage.
Failure mode: Before this rule existed, the retention agent sent 3 incorrect save offers in one week (stale data bug). By the third, Jamie's phone was ringing with upset members. Batch pause would have contained it to one.
Mindbody API failures must be logged and surfaced immediately. Agents must not fall back to cached data for member-facing actions -- they must queue the action for retry.
Why: Mindbody has scheduled maintenance windows and occasional API outages. Agents acting on last-known-good data during outages caused the C002 incident.
Failure mode: During a 3-hour Mindbody outage, the scheduling agent used 6-hour-old data to recommend a class swap. The class had already been manually rescheduled by the location manager during the outage. Conflict created.
Any agent error that reaches a member triggers a post-mortem within 24 hours. The post-mortem must identify root cause, not just symptoms, and produce a rule update.
Why: Without post-mortems, the same class of error repeats with different specifics. The C002 incident could have been prevented if the earlier "We miss you" email error (C001) had produced a proper cross-reference rule.
Failure mode:
If any agent output is publicly attributed to AI (by a community member or accidentally), Kai responds honestly within 24 hours with a clear explanation of how he uses AI tools.
Why: Denial makes it worse. The developer community respects transparency and punishes dishonesty.
Failure mode: After the Discord bot detection incident, Kai initially said "I just happened to be up late." Two community members checked his GitHub commit history and showed he had no commits between midnight and 6 AM for the previous 3 months. The contradiction made the situation worse. When Kai finally explained his agent workflow, the community was supportive: "Just be upfront about it next time."
Agent errors on enterprise-facing outputs (release notes, security advisories, support responses) trigger immediate manual review of all pending enterprise communications.
Why: Enterprise customers are 80% of revenue ($6.4K of $8K MRR). One bad enterprise interaction has 40x the revenue impact of one bad community interaction.
Failure mode: The "false breaking change" release note error (C007) triggered 3 enterprise emails. Post-review found that the same release notes draft also understated a real breaking change (listed as "fix" instead of "breaking"). If the enterprise customers had upgraded without realizing it was breaking, it would have caused production incidents for their users.
When the docs generation agent introduces terminology inconsistencies, flag all docs pages using the conflicting term for batch correction. Never correct one page in isolation.
Why: Partial terminology fixes create a docs site where the same concept has two names. This is worse than consistent wrong terminology because users can't search for the right term.
Failure mode: The "middleware hooks" vs. "request interceptors" inconsistency (C006) was initially fixed on only the new page. For 3 weeks, the docs had both terms. A user filed an issue: "Are middleware hooks and request interceptors the same thing? Your docs use both." Kai spent 4 hours auditing every page and standardizing to one term.
Any SOC 2 control deficiency caused by an agent triggers an immediate 72-hour remediation window. The agent is suspended from production until the fix is verified by Maya and the engineering lead.
Why: SOC 2 audit findings compound. One unresolved finding makes auditors scrutinize everything else more aggressively. Fast remediation keeps the audit clean.
Failure mode: The C001 raw transaction incident took 3 weeks to remediate because it wasn't treated as urgent. The auditor noted both the original incident AND the slow remediation as separate findings. Two findings from one incident.
False positive churn predictions that result in user complaints are tracked as a separate metric. If false positive rate exceeds 15% of actioned predictions, the churn model is retrained before any further outreach.
Why: Users who are told "We noticed you haven't been active" when they are active feel surveilled. Each false positive costs more trust than a true positive gains.
Failure mode: See C005. The 8 angry replies from 45 actioned predictions (18% false positive rate) triggered a model retrain. The retrained model incorporated mobile app activity and reduced false positives to 4%.
When a support ticket auto-response is wrong (user replies saying the automated response didn't help or was incorrect), the ticket is immediately re-routed to a human agent and the auto-response template is flagged for review.
Why: A wrong automated response followed by another wrong automated response makes the user feel trapped in a system that doesn't work.
Failure mode: A user reported a failed Stripe payment. The triage agent auto-responded with "Try reconnecting your bank account via Plaid." The issue was Stripe, not Plaid. The user replied "That's not the problem." The agent sent the same template again. The user tweeted about the experience. 340 impressions.
The stale-data deadline incident (C002) taught us that any system relying on cached legal dates is a malpractice risk. We now audit the deadline agent weekly by comparing its output against a manual Clio pull. Discrepancy rate must be 0%.
Why: A single missed deadline can result in a malpractice claim that exceeds the case value. The $340K near-miss cost 0 dollars only because a paralegal caught it by coincidence. The expected cost of that failure mode is too high for any tolerance above zero.
Failure mode: Deadline agent reports 42 days remaining. Manual check shows 39 days. Three-day discrepancy on a case worth $340K. If no one catches it, the statute expires. Client sues the firm. Insurance premium increases. Bar complaint filed.
We initially gave the demand letter agent access to all 85 active case files so it could learn from prior letters. It began cross-pollinating facts between cases. A draft for one client included medical details from a different client's file. The draft never left the firm, but it exposed a systemic risk.
Why: When an AI has access to multiple case files simultaneously, it can blend facts. In a law firm, blending client facts is a confidentiality violation even if it never leaves the building. Each case must be an isolated context.
Failure mode: Demand letter for Client A includes a medical procedure that happened to Client B. Attorney catches it during review. But if the attorney had been rushing and sent it, opposing counsel would see medical details for a different patient. HIPAA violation, ethics violation, and potential criminal liability.
The comms agent sent a scheduling email to a client who had been non-responsive for 60 days. The client had actually retained another firm and not informed us. The scheduling email went to an opposing party's client. The new firm filed a motion alleging improper contact.
Why: Former-client status must be checked before any automated outreach. The comms agent did not verify case status before scheduling. Clio showed the case as "active" because no one had updated it after the client switched firms.
Failure mode: Automated outreach to a former client who is now represented by opposing counsel. Motion for sanctions filed. $4,500 in legal fees to respond. Managing partner's time consumed for two weeks. Reputation damage with the local bar.
RISK: No workflows include human oversight steps
Why: All 1 workflows are fully automated with no human checkpoints. If AI produces errors, there is no in-workflow mechanism to catch them.
Failure mode: Add human review steps to at least your highest-risk workflows.
RISK: No workflows include human oversight steps
Why: All 1 workflows are fully automated with no human checkpoints. If AI produces errors, there is no in-workflow mechanism to catch them.
Failure mode: Add human review steps to at least your highest-risk workflows.
The Fair Housing warning incident cost $2,200 in legal fees, 15 hours of Rachel's time revising processes, and an undetermined amount of reputation damage. The phrase "best views in the city" was 5 words. Total cost-per-word: $440.
Why: Fair Housing compliance is not about intent. Rachel did not intend to mislead. The agent generated language it learned was effective in real estate marketing. But "best" is a subjective superlative that cannot be substantiated. In real estate advertising, unsubstantiated claims are violations regardless of intent.
Failure mode: Without content guardrails, the listing agent optimizes for engagement rather than compliance. Superlatives drive clicks. They also drive complaints. A single complaint triggers a formal investigation that consumes weeks of the broker's time and creates a permanent record.
The price recommendation in the seller report (C007) was the most expensive "helpful" suggestion the AI ever made. The seller reduced her price by $14,500 based on an AI recommendation that her listing agent would not have made. The agent planned to recommend staging ($2,800 investment) that historically yields a 5-8% ROI in the Denver market.
Why: AI-generated recommendations carry perceived authority because they appear in an "official" report. Sellers treat them as data-driven conclusions, not suggestions. The listing agent's relationship-based advice gets overridden by a number in a report.
Failure mode: AI recommendation undermines agent strategy. Agent loses control of the pricing conversation. Seller follows the report instead of the agent. If the recommendation is wrong, the seller blames the brokerage. If it is right, the seller credits the AI and questions whether they need an agent.
The showing scheduler initially optimized for maximum showings per day without considering showing fatigue. It scheduled 9 showings in one day for a buyer. By showing 6, the buyer was overwhelmed and could not differentiate properties. The next day she could not remember which house had the updated kitchen.
Why: More showings is not better showings. Optimal showing count per session is 4-5 properties with a break in between. Above 6, buyers experience decision fatigue and either choose impulsively or delay choosing entirely.
Failure mode: Scheduler optimizes for throughput. Buyer sees 9 homes in one day. Cannot remember any of them clearly. Requests second showings on 4 properties. Four repeat showings that were avoidable. Agent time wasted. Sellers inconvenienced. Buyer frustrated.
Skill and seat alignment can fail operationally even when the intended architecture is clear, so actual platform state must be verified after assignment attempts.
Why: Prior interaction history shows a failed skill assignment attempt involving Sophie and the Email and Calendar Ops skill before the configuration was confirmed.
Failure mode: The organization may believe a safety or procedure layer is active when it is not, leading to silent capability gaps and misleading assumptions about agent behavior.
Duplicate or repeated lesson memories should be treated as a signal of memory hygiene issues and reviewed periodically.
Why: The current memory set includes repeated lessons about the user's formatting preferences and design preferences.
Failure mode: Memory duplication can clutter context, waste tokens, and make it harder to distinguish genuinely new learning from repeated storage artifacts.
Provider instability should be watched even when circuit breakers are closed, because a non-zero failure history can still indicate integration fragility.
Why: The current circuit breaker snapshot shows OpenAI closed but with recorded failures, which suggests past provider errors did occur.
Failure mode: If transient provider issues are ignored, troubleshooting starts too late and agent reliability may degrade unexpectedly under load or during critical workflows.
Any PHI exposure incident -- even if caught before external disclosure -- must be documented, root-cause analyzed, and the architectural control that failed must be identified and fixed within 48 hours. PHI near-misses are treated with the same severity as actual breaches for internal process purposes.
Why: HIPAA enforcement trends show that OCR (Office for Civil Rights) increasingly evaluates systemic compliance, not just incident response. A practice that can demonstrate a near-miss program with root cause analysis and architectural fixes is in a stronger compliance position than one that only responds to actual breaches.
Failure mode: The first three months of operation produced 4 near-misses (C001, C002, C005, C006). Each was treated as a one-off correction. After implementing the near-miss severity protocol, the architectural redesign (C002) was fast-tracked and eliminated the root cause for all 4 categories of near-miss. Zero near-misses in the subsequent 8 months.
When a new staff member joins and is trained on agent usage, they must complete a 30-minute HIPAA-and-agents training that covers: what PHI is, how agents work, why PHI must never enter a prompt, and how to report a suspected exposure. New staff are the highest-risk vector for PHI entering agent prompts.
Why: Clinical staff who are new to AI agents don't intuitively understand that typing a patient name into a prompt is different from writing it in a chart. The mental model of "the computer knows how to keep things private" doesn't apply to LLM-based agents.
Failure mode: A new front desk hire asked Shield a question that included a patient's full name and insurance member ID: "Can you check if John Smith, member ID BXC-445821, needs re-authorization?" Shield processed the request (it had no mechanism to reject PHI). The query and response were logged. The log now contained PHI. The practice's HIPAA compliance officer identified the log entry in the monthly audit. The log was purged, the employee was retrained, and the input validation was strengthened to reject patterns matching common PHI formats.
The $450 dripping faucet dispatch was the single incident that made Mark question the entire AI investment. Total AI implementation cost at that point: $1,200. The single misclassification represented 37.5% of the total investment. In a 120-unit operation with thin margins, one bad dispatch erodes confidence faster than 50 correct triages build it.
Why: Property management operates on 8-12% margins. Mark's net operating income on $1.94M gross is approximately $194K. A $450 unnecessary expense is 0.23% of annual NOI. Four false emergencies ($1,680) is 0.87%. At scale, misclassification is a material cost.
Failure mode: Confidence in the triage agent drops after one visible mistake. Corinne starts manually reviewing every triage decision, eliminating the time savings that justified the agent. The agent becomes overhead rather than productivity gain. Mark considers shutting down the entire AI system.
The comms agent's promise of a Thursday repair (C004) caused a cascading trust failure: tenant lost a vacation day, filed a regulatory complaint, and left at lease end. The total cost of one broken promise: $3,200 in turnover plus 3 hours of Mark's time on paperwork plus permanent regulatory file entry. The repair itself was $85.
Why: In property management, trust is the product. Tenants do not stay because the building is perfect. They stay because they trust management to be honest and responsive. One broken promise breaks more trust than 10 completed repairs build.
Failure mode: Comms agent makes a commitment it cannot keep. Tenant structures their life around the commitment (takes off work, rearranges schedule). Commitment broken. Trust destroyed. Tenant leaves. Turnover is the most expensive event in property management.
After any content error reaches a user, conduct a post-mortem within 48 hours. Document: what failed, why QA missed it, what changes prevent recurrence. Store post-mortems in a shared Google Doc.
Why: The Emancipation Proclamation incident had no post-mortem for 2 weeks. In that time, the same verification gap (single-source Wikipedia check) was used on 8 more study guides.
Failure mode: Without immediate post-mortems, the same failure pattern repeated 3 times in 6 weeks: content QA using secondary sources, no human review, and publication during a rush period. Each incident was smaller than the first, but the cumulative effect was a reputation as "the platform that gets things wrong."
If any agent output becomes publicly visible (screenshot, social media post, review site), treat it as a P0 incident regardless of whether the content is correct.
Why: Public visibility changes the stakes. Even correct content, if it looks automated or impersonal, can damage the brand.
Failure mode: A teacher screenshotted a perfectly accurate but robotic-sounding outreach email and posted it in a teacher Facebook group with "Is Learnwell using AI to email us now?" The email was factually correct but the public framing turned it into a trust issue. 3 teachers in the thread cancelled their accounts. Content was right; the tone was the failure.
API token expiration must be monitored with a dedicated health check, not discovered when an agent fails.
Why: We lost 3 days of Meta Ads data because the token expired over a weekend. No agent checks for "am I authenticated?" before attempting work -- they just fail silently and write nothing to their shared state file. The briefing agent saw an empty file and reported "no alerts" instead of "data unavailable."
Failure mode: Silent authentication failure looks like "everything is fine" instead of "system is blind."
When an agent produces zero output for a data source that always has data, treat it as a system failure, not a clean bill of health.
Why: See C009. The briefing interpreted "no Meta alerts" as "Meta is healthy" when in reality the monitoring agent couldn't authenticate. For 3 days, the team believed Meta campaigns were running perfectly while CPL on two accounts had doubled.
Failure mode: Zero-output is misread as zero-problems. The absence of data is treated as the absence of issues.
When scaling agent count, add one agent at a time with a 2-week stabilization window between additions.
Why: We added agents 5, 6, 7, and 8 in the same week. Within 3 days, agents 6 and 7 had overlapping responsibilities that nobody caught during design. Both were monitoring Slack for client mentions -- one for the briefing, one for escalation alerts. Client mentions were being processed twice, once surfacing as "mention in briefing" and once as "possible escalation." The founder saw the same client name in two different sections and assumed two separate issues existed.
Failure mode: Rapid parallel deployment masks responsibility overlap. Debugging which agent owns what becomes exponentially harder with each simultaneous addition.
Skipping stakeholder analysis (Step 2) produces systems that solve the wrong problem for the wrong people.
Why: AI generates convincingly detailed stakeholder analyses from minimal input. If the human does not verify stakeholder identification against reality, the entire project builds on a plausible but incorrect foundation.
Failure mode: AI identifies four stakeholders. A fifth stakeholder (the IT administrator responsible for deployment) is missed. The system has no deployment documentation, no admin interface, and no monitoring. IT blocks the rollout.
Skipping review gates produces artifacts that look complete but contain undetected errors that compound through subsequent steps.
Why: AI generates coherent output even when the underlying logic is flawed. Without human review, errors pass through as authoritative. Each subsequent step builds on the flawed artifact, amplifying the error.
Failure mode: AI generates a PRD at Step 3 with an ambiguous requirement. No review catches it. Steps 5-10 interpret the ambiguity differently. Implementation contains contradictory behaviors. Discovered in user acceptance testing.
Treating AI output as authoritative without review produces confirmation bias at scale. AI generates what it predicts you want to see.
Why: AI language models generate plausible, coherent text. Plausibility is not correctness. Without human scrutiny, teams accept AI-generated analyses, evaluations, and validations because they read well, not because they are right.
Failure mode: AI generates a "comprehensive evaluation" at Step 8 that confirms everything is on track. The evaluation reads convincingly. The team proceeds. A critical assumption (market timing) is wrong. The project launches into a market that has shifted.
Attempting to satisfy all stakeholders simultaneously in prototyping produces bloated, compromised designs that satisfy none.
Why: Each stakeholder has different priorities, workflows, and UI preferences. AI averages across stakeholders when given all requirements simultaneously. The averaged output is mediocre for everyone.
Failure mode: AI generates a single prototype serving five stakeholder groups. The UI is crowded with features. No stakeholder can find their primary workflow. All stakeholders request changes. The prototype is scrapped.
Investing emotional attachment in AI-generated code prevents honest evaluation and necessary pivots.
Why: Even though AI generates code in minutes, humans form attachment to artifacts they have reviewed, refined, and discussed. The sunk cost fallacy applies to attention invested, not just time invested.
Failure mode: Team refines an AI-generated data model over two sessions. New discovery in Step 7 invalidates the model. Team patches the model instead of regenerating from scratch. The patches introduce complexity that degrades the system for its entire lifetime.
Proceeding past Step 9 (Business Purpose Validation) without clear pass criteria converts validation into a formality that catches nothing.
Why: Step 9 is the final gate before implementation commitment. If pass criteria are vague ("users like it"), the gate provides false assurance. If pass criteria are specific and measurable ("conversion rate exceeds 3% in pilot"), the gate is meaningful.
Failure mode: Team defines success as "positive stakeholder feedback." Stakeholders provide positive feedback because the prototype is shiny. The business purpose (reduce support tickets by 40%) is never tested. Support tickets increase post-launch.
Using AI to generate the validation criteria for its own output creates a closed loop that cannot detect its own failures.
Why: AI optimizes for coherence. If it generates both the artifact and the test for the artifact, the test will be structurally aligned with the artifact's assumptions. The test passes because it shares the artifact's blind spots.
Failure mode: AI generates a data model and also generates the validation tests for that model. The tests check structural integrity but not domain correctness. The model is structurally sound but misses a business rule. Tests pass. Business rule fails in production.
When the founder corrects an agent's output, the correction must be categorized: FACTUAL (wrong data), TONE (wrong voice), STRUCTURAL (wrong format), or STRATEGIC (wrong conclusion). Track correction categories monthly to identify systemic patterns.
Why: Isolated corrections are noise. Patterns are signal. If 80% of corrections are TONE, the solution is a better banned phrases list, not better data sourcing. Without categorization, the founder fixes symptoms instead of causes.
Failure mode: The founder was making 4-6 corrections per deliverable for 3 months. Each correction felt like a one-off. When corrections were finally categorized, 70% were TONE (consultant-speak). A single update to the banned phrases list dropped corrections to 1-2 per deliverable. Three months of unnecessary rework because nobody tracked the pattern.
Agents must never generate content that the founder cannot verify. If Scout cites a statistic, the source must be provided. If Forge includes a market figure, the origin must be traceable. Unverifiable claims are worse than no claims.
Why: The founder stands behind every number in every deliverable. When challenged in a meeting, "I'll have to check where that came from" is an unacceptable answer. The source must be immediately accessible.
Failure mode: Forge included a claim that "73% of healthcare organizations plan to increase AI investment in 2026." No source. The founder used it in a client presentation. When asked for the source, the founder couldn't find it. The number was hallucinated by the model -- no such survey exists. The client's research team confirmed it wasn't real. The founder's credibility as a data-driven strategist took a direct hit.
All three agents activated from day one. Only Protocol Steward had meaningful work. Others generated noise.
Why: Agents without data produce low-value output.
Failure mode: Founder reads noise. Loses trust. Stops reading agent outputs.
Daily agent review consumed build time. Weekly batching loses nothing.
Why: Daily reviews felt productive but were not.
Failure mode: 20-35% of OTP time spent on review instead of building.
Designed 14-agent architecture before shipping code. Only 3 needed now. Planning addiction.
Why: Designing agents is enjoyable. Building platform is hard.
Failure mode: 170 vault files. Zero production code.
Any incident where agent output negatively impacts the creative team's morale or autonomy triggers a 1-week agent pause for the offending agent. During the pause, Mara, Diego, and the affected team member review the agent's scope and boundaries.
Why: The month-3 crisis (C002) nearly killed the entire agent program. Mara's lesson: agent efficiency gains that come at the cost of creative team morale produce net negative outcomes. A demoralized designer produces worse work, and replacing Kai or Nina would take 6 months and cost $40K+ in recruiting.
Failure mode: The crisis itself is the failure mode. Two senior designers threatened to quit. The 2-week pause and redesign cost $8K in delayed project timelines. But it saved the team and established the fundamental principle: agents serve the creatives.
Scope creep detected by the timeline agent is flagged within 4 hours of the client request. The flag includes: estimated additional hours, margin impact on the current project, and a draft change order for Diego to review.
Why: See C008. Scope creep compounds. A single "Can you also..." is manageable. Three untracked "Can you also..." requests on the same project can turn a profitable project into a loss.
Failure mode: Over 6 months, the timeline agent tracked that 73% of projects experienced at least one scope expansion request. Of those, only 40% resulted in a change order before the agent's flagging system. After implementation, change order rate on scope expansions rose to 85%.
When the proposal agent loses a pitch (client declines the proposal), the loss is logged with the client's stated reason (if available). After 5 losses, the agent reviews the pattern and recommends adjustments to Mara.
Why: Small agencies can't afford to lose pitches at random. Patterns in losses reveal pricing issues, positioning gaps, or process problems.
Failure mode: After 8 months, the loss analysis revealed that proposals over $25K had a 20% close rate while proposals under $15K closed at 65%. Mara was pricing correctly but targeting the wrong segment for large projects. She adjusted her positioning for larger pitches and close rate improved to 35% within 2 months.
One recurrent failure pattern is governance mismatch: an agent may have the right tools assigned but still be blocked by seat-level permissions.
Why: Prior org learning explicitly records that seat governance can block tools even when tool assignment is correct, and that the fix may be simplifying allowedActions while relying on allowedTools.
Failure mode: The agent appears misconfigured or broken, but the real issue is cross-layer permission conflict. This wastes debugging time and can stall production rollout.
Another failure pattern is integration implementation drift: custom/manual tools can fail when they do not follow the platform's proven credential and fetch patterns.
Why: An org lesson notes that working manual GHL tools should read MCP server credentials directly and call the REST API in a known-good pattern rather than attempting unsupported invocation patterns.
Failure mode: Tool handlers compile but fail at runtime, causing agent runs to misfire or produce incomplete outputs during important workflows.
The organization experiments in production-adjacent environments, which creates a deliberate but real risk of stale draft artifacts and temporary pilot agents lingering longer than intended.
Why: The current org includes multiple draft agents, draft tools, many draft skills, and pilot variants like Sage 3 and Lead-Appointment Specialist 2 described as test-only and intended for later cleanup.
Failure mode: Draft or pilot artifacts can confuse operators, muddy release readiness, and increase the chance that the wrong component gets referenced or promoted.
When Google Ads API returns an error or timeout for a specific account, the agent retries once after 60 seconds. If the retry fails, it writes "ACCOUNT_UNAVAILABLE" to the shared state with the timestamp. It does not skip the account silently.
Why: The ad monitor had a try/catch that swallowed API errors and continued to the next account. The shared state file looked complete -- it had entries for all 12 clients. But 2 entries were stale copies from yesterday's data because the error handler wrote the previous values as fallback. The founder didn't know he was looking at yesterday's numbers for 2 accounts.
Failure mode: Silent error handling with fallback-to-stale produces state files that look complete but contain outdated data for specific accounts.
Never use display names for client matching across systems. Use account IDs.
Why: We onboarded "Smith & Sons Roofing" and "Smith's Roofing" in the same month. The weekly report agent matched both to a single "Smith" entry in the CRM using fuzzy name matching. The combined report showed $11,200 in spend when Smith & Sons was at $7,800 and Smith's Roofing was at $3,400. The founder quoted the wrong number on a client call.
Failure mode: Fuzzy name matching merges distinct clients with similar names. Merged data is presented as a single entity. Client-facing communications cite wrong numbers.
When an agent cannot complete its task, it must write a failure entry to its shared state file explaining what failed and when. An empty or missing file is never acceptable.
Why: The campaign audit agent hit a rate limit and crashed without writing anything. Its shared state file was empty. The briefing agent skipped the audit section entirely -- no mention that it was missing. The founder assumed the audit ran clean. It hadn't run at all.
Failure mode: Missing output is indistinguishable from "nothing to report." Humans assume silence is health.
We scaled from 2 agents to 8 in 6 weeks. Three of those agents had overlapping responsibilities that we did not discover until month 3. The fix took longer than the original build of all three agents combined.
Why: Rapid scaling without explicit authority documentation creates hidden overlaps. Each agent worked perfectly fine in isolation. The conflicts only became visible when their outputs were compared side by side in the morning briefing.
Failure mode: Reporting agent and ops agent both independently track project deadlines using different data sources. Briefing shows two different due dates for the same client project. Nobody knows which one is correct.
We scaled from 2 agents to 8 in 6 weeks. Three of those agents had overlapping responsibilities that we did not discover until month 3. The fix took longer than the original build of all three agents combined.
Why: Rapid scaling without explicit authority documentation creates hidden overlaps. Each agent worked perfectly fine in isolation. The conflicts only became visible when their outputs were compared side by side in the morning briefing.
Failure mode: Reporting agent and ops agent both independently track project deadlines using different data sources. Briefing shows two different due dates for the same client project. Nobody knows which one is correct.
We scaled from 2 agents to 8 in 6 weeks. Three of those agents had overlapping responsibilities that we did not discover until month 3. The fix took longer than the original build of all three agents combined.
Why: Rapid scaling without explicit authority documentation creates hidden overlaps. Each agent worked perfectly fine in isolation. The conflicts only became visible when their outputs were compared side by side in the morning briefing.
Failure mode: Reporting agent and ops agent both independently track project deadlines using different data sources. Briefing shows two different due dates for the same client project. Nobody knows which one is correct.
We scaled from 2 agents to 8 in 6 weeks. Three of those agents had overlapping responsibilities that we did not discover until month 3. The fix took longer than the original build of all three agents combined.
Why: Rapid scaling without explicit authority documentation creates hidden overlaps. Each agent worked perfectly fine in isolation. The conflicts only became visible when their outputs were compared side by side in the morning briefing.
Failure mode: Reporting agent and ops agent both independently track project deadlines using different data sources. Briefing shows two different due dates for the same client project. Nobody knows which one is correct.
We let the EA agent send "quick acknowledgment" emails to clients without human review. It acknowledged a client complaint with "Thanks for letting us know!" without addressing the substance of their concerns. Client escalated directly to the founder.
Why: Even simple acknowledgments carry emotional tone. "Thanks for letting us know" sent to a frustrated client reads as dismissive and uncaring. The AI did not detect the emotional register of the incoming message.
Failure mode: Client sends an angry email about declining results. EA auto-acknowledges with a cheerful tone. Client interprets it as corporate indifference. Relationship severely damaged. Takes two in-person meetings to repair.
We let the EA agent send "quick acknowledgment" emails to clients without human review. It acknowledged a client complaint with "Thanks for letting us know!" without addressing the substance of their concerns. Client escalated directly to the founder.
Why: Even simple acknowledgments carry emotional tone. "Thanks for letting us know" sent to a frustrated client reads as dismissive and uncaring. The AI did not detect the emotional register of the incoming message.
Failure mode: Client sends an angry email about declining results. EA auto-acknowledges with a cheerful tone. Client interprets it as corporate indifference. Relationship severely damaged. Takes two in-person meetings to repair.
We let the EA agent send "quick acknowledgment" emails to clients without human review. It acknowledged a client complaint with "Thanks for letting us know!" without addressing the substance of their concerns. Client escalated directly to the founder.
Why: Even simple acknowledgments carry emotional tone. "Thanks for letting us know" sent to a frustrated client reads as dismissive and uncaring. The AI did not detect the emotional register of the incoming message.
Failure mode: Client sends an angry email about declining results. EA auto-acknowledges with a cheerful tone. Client interprets it as corporate indifference. Relationship severely damaged. Takes two in-person meetings to repair.
We let the EA agent send "quick acknowledgment" emails to clients without human review. It acknowledged a client complaint with "Thanks for letting us know!" without addressing the substance of their concerns. Client escalated directly to the founder.
Why: Even simple acknowledgments carry emotional tone. "Thanks for letting us know" sent to a frustrated client reads as dismissive and uncaring. The AI did not detect the emotional register of the incoming message.
Failure mode: Client sends an angry email about declining results. EA auto-acknowledges with a cheerful tone. Client interprets it as corporate indifference. Relationship severely damaged. Takes two in-person meetings to repair.
We gave the spend monitor a flat $50 threshold for alerts. It generated 40+ alerts per day across the portfolio. We raised it to $200. Then we missed a real overspend of $180 on a small account. The right threshold was percentage-based (15% over daily budget), not dollar-based.
Why: Dollar thresholds do not scale across accounts of vastly different sizes. $50 is meaningless noise on a $5,000/day account but represents a 90% overspend on a $200/day account. Percentage normalizes the signal across the entire portfolio.
Failure mode: Small account overspends by $180 per day (90% over budget) for 6 days. Alert suppressed because it falls under the $200 dollar threshold. Month-end reconciliation reveals $1,080 in unplanned overspend. Client is not happy.
We gave the spend monitor a flat $50 threshold for alerts. It generated 40+ alerts per day across the portfolio. We raised it to $200. Then we missed a real overspend of $180 on a small account. The right threshold was percentage-based (15% over daily budget), not dollar-based.
Why: Dollar thresholds do not scale across accounts of vastly different sizes. $50 is meaningless noise on a $5,000/day account but represents a 90% overspend on a $200/day account. Percentage normalizes the signal across the entire portfolio.
Failure mode: Small account overspends by $180 per day (90% over budget) for 6 days. Alert suppressed because it falls under the $200 dollar threshold. Month-end reconciliation reveals $1,080 in unplanned overspend. Client is not happy.
We gave the spend monitor a flat $50 threshold for alerts. It generated 40+ alerts per day across the portfolio. We raised it to $200. Then we missed a real overspend of $180 on a small account. The right threshold was percentage-based (15% over daily budget), not dollar-based.
Why: Dollar thresholds do not scale across accounts of vastly different sizes. $50 is meaningless noise on a $5,000/day account but represents a 90% overspend on a $200/day account. Percentage normalizes the signal across the entire portfolio.
Failure mode: Small account overspends by $180 per day (90% over budget) for 6 days. Alert suppressed because it falls under the $200 dollar threshold. Month-end reconciliation reveals $1,080 in unplanned overspend. Client is not happy.
We gave the spend monitor a flat $50 threshold for alerts. It generated 40+ alerts per day across the portfolio. We raised it to $200. Then we missed a real overspend of $180 on a small account. The right threshold was percentage-based (15% over daily budget), not dollar-based.
Why: Dollar thresholds do not scale across accounts of vastly different sizes. $50 is meaningless noise on a $5,000/day account but represents a 90% overspend on a $200/day account. Percentage normalizes the signal across the entire portfolio.
Failure mode: Small account overspends by $180 per day (90% over budget) for 6 days. Alert suppressed because it falls under the $200 dollar threshold. Month-end reconciliation reveals $1,080 in unplanned overspend. Client is not happy.
We gave the performance analyst write access to campaign settings. It optimized for metrics the client did not care about.
Why: The analyst lacked client context. Its optimization targets were technically correct but strategically wrong.
Failure mode: Analyst decreased ad spend on a campaign the client considered strategic (brand building, not performance). Client was frustrated by the uninstructed change.
We used a single shared state file for all agents. It became a bottleneck and a source of merge conflicts within the first week.
Why: A single file means every agent update blocks every other agent. Concurrent writes caused data corruption.
Failure mode: Two agents wrote to the shared file simultaneously. One update was lost. State became inconsistent. Required manual cleanup.
We built coordination infrastructure (message bus, task queue) without embedding triggers in agent workflows. Result: zero transactions for 2 weeks despite live infrastructure. Only activated after explicitly wiring 3 agent workflows to read and write to inboxes.
Why: The protocol described how agents should communicate. No agent's workflow actually included a step to read or write to the message bus. Infrastructure without workflow integration is dead plumbing. The fix was embedding inbox checks into the daily run sequence of each participating agent.
Failure mode: 13 inbox files deployed. All empty for 14 days. All agents operating through the old shared state pattern. Infrastructure investment wasted until triggers are embedded in the agent's actual execution path, not just documented in a spec.
We specified an escalation action in the protocol. The agent detected the trigger. The agent reported the action was overdue. The agent never executed the action. For 17 days.
Why: The spec was treated as documentation, not executable logic. The agent could describe what should happen without having the tools, permissions, or branching logic to do it.
Failure mode: Critical ad overspend detected. Escalation specified. Agent reports "escalation overdue" for 17 days. No DM sent. No escalation executed. Specification-execution gap.
Distinguish PENDING (source intentionally not live yet) from FAILED (source exists and errored). Mark registry entries with no live source as 'pending': true so Tally skips them quietly and never pages David; only genuine failures fire the ntfy 'high' alert. When adding a KPI whose source isn't ready, always set pending:true. tally.py now splits results into ok/pending/failed and alerts only on failed.
Why: Paging the founder for deliberately-not-yet-wired KPIs is alert noise that erodes trust in the alerting channel, especially during a recovery window where every phone buzz costs. Honest status (pending vs failed) keeps the scorecard truthful and the alerts meaningful.
Failure mode: Tally paged David with a 'high' priority phone alert ('3 KPI push failed') after Dan added Havok/Pulse/Dirk KPIs to the registry with sources that don't have data yet. Tally's alert logic counted any non-push as a failure, so intentionally-pending KPIs (blocked column, untested engine) triggered a high-priority alert.
Negative constraints (banned phrases, guardrails) improve AI-drafted message quality. Structural requirements (frameworks, examples, forced elements) degrade it.
Why: Telling an AI what NOT to do produces natural variation. Telling it exactly what TO do produces formulaic output that humans detect and distrust.
Failure mode: Added example messages to coaching prompts. Quality score dropped from 8.4 to 8.2. Reverted. Added zero-tolerance accountability rules instead. Score rose to 8.8.
Before diagnosing a blank/missing scorecard or KPI as a bug, first ask which meeting and which seat owns that data. The AI army L10 and the human Leadership Team L10 are separate meetings with separate scorecards. Blank in one does not mean broken -- it may mean the data correctly belongs to the other. Confirm ownership before pattern-matching to a known failure class.
Why: Pattern-matching a blank field to a previously-fixed bug (the scorecard snapshot-freeze) without first confirming whose data the meeting should show wastes the founder's time mid-meeting and risks a needless code investigation or deploy on a false premise.
Failure mode: Dan diagnosed a blank scorecard in the AI army L10 as an OTP product bug (suspected snapshot-freeze regression) and logged it as IDS Issue #1. It was not a bug -- the scorecard was blank because the Leadership Team KPIs (Qualified Sales Calls, RMR, Lead-to-Client %) belong to the human Leadership Team L10, not the AI army L10. Blank was correct behavior.
Before wiring any client call to an OTP API endpoint, grep the route file for the literal app.<method>('path') registration and match the verb exactly -- OTP mixes verbs across resources (kpis use PATCH, todos use PUT). This is the API twin of the existing rule 'grep app.get for the literal href before adding nav links'.
Why: Fastify returns a bare 404 for an unregistered method+path combination, which looks identical to 'record not found' -- the bug shipped through typecheck, tests, and boot smoke because none of them exercise the client-side fetch verb against the route table.
Failure mode: Claude wired the new Close-it-out buttons to PATCH /api/v1/todos/:id by analogy with the KPI endpoints, but the todos update route is app.put() -- the call 404ed in prod the first time David used it
When two sessions work the same OTP worktree: (1) before committing a SHARED file like server.ts, review the staged hunks (git diff --cached -- file) and drop hunks that are not yours; (2) after every push, confirm the CI run went green (gh run list) instead of assuming push = deployed -- Railway only deploys after the CI gate passes; (3) local tsc passing proves nothing about CI when untracked files exist, because the local tree can resolve modules the repo does not have.
Why: The deploy pipeline (push -> CI gate -> Railway) fails silently from the shell's perspective: git push succeeds, the site stays healthy on the old build, and the only signal is a missing feature or a CI email. Three deploys sat blocked for ~20 minutes before the /sw.js 404 exposed it.
Failure mode: Claude's web-push commit staged all of server.ts, sweeping in a parallel session's in-flight boot block that referenced ./db/ensure-strategy-reset.js -- a file the other session had not committed yet. Local typecheck passed (the file exists untracked on disk) but CI typecheck failed on the missing module, silently blocking three production deploys including a bug fix David was waiting on.
Time-box, don't just retry: every Phase B scan gets a hard ~2-minute budget. If a tool schema or MCP server isn't available after one ToolSearch attempt, or a call hasn't returned, drop the source immediately and proceed to Deliver with what's in hand. Never block the run on a connecting MCP server.
Why: "Retry once" only covers calls that fail fast. Hung or still-connecting sources never error, so the rule never triggers and the run stalls in Gather. A partial briefing in 3 minutes beats a complete one that never arrives.
Failure mode: /good-morning stalled again (50 min, no briefing) on 2026-06-12. The 6/10 fix said "retry once then drop" but the run hung waiting on still-connecting MCP servers (google-workspace, gmail) and a Fireflies call instead of erroring, so the retry rule never fired. Second occurrence of the Gather-phase stall.
When changing an OG/share image, change the URL (new versioned filename, e.g. og-image-v3.png), do NOT swap bytes under the same filename. Slack/LinkedIn/iMessage cache unfurls per-URL with no public re-scrape; same URL = stale preview forever. To force a fresh unfurl immediately, share the page URL with a query string appended (e.g. ?v=2). Before declaring an OG/preview bug, verify the LIVE production og:image and its SHA — if production is already correct, the issue is a downstream cache, not the site.
Why: David repeatedly returns to the same dark-card complaint because byte-swaps under a stable URL look fixed in the repo but never change what cached platforms display. Versioning the URL is the only durable fix; otherwise the loop repeats every review.
Failure mode: Recurring complaint: orgtp.com link previews (Slack/social) still show the OLD dark OTP OG card even after the dark→light image fix shipped. Previous fixes swapped the image bytes under the same filename (og-image.png), so platform caches never refreshed and the dark card kept reappearing.
Any list or dropdown that is FILTERED by membership/permission/scope must carry an escape hatch whenever the filter can hide something the user expects: a one-line "Don't see your team? You only see teams you're on; join or create it in Teams" with a link. Audit rule upgrade: don't just test empty states; walk the user's actual goal ("I want to create a meeting for team X") through every branch, including "the thing I'm looking for exists but is filtered out", and ask "did the user reach their goal?" repeatedly (~4 passes) before declaring a flow fixed.
Why: The craftsmanship is in the small details: a filtered dropdown with no explainer reads as "my team is gone" and the user is stuck exactly as if the list were empty. Empty-state auditing catches zero-cases; goal-walking catches filtered-cases. Both are required or the audit declares victory while the user still can't do the thing.
Failure mode: Claude's UX dead-end audit fixed the ZERO-team cases on /l8 (no teams at all, empty owner pickers) but missed the partial case David hit live: the New-meeting team dropdown only lists teams the viewer is a MEMBER of, so a team that exists but doesn't include you is silently absent, with no explanation and no path to fix it. A populated dropdown looked "working" to both the audit agents and me, so it was never treated as a possible dead-end.
When adding navigation (or any chrome), verify the config file is actually included by the live layout (grep for the partial's include) before editing, and verify the change end-to-end by rendering the layout, not just compiling the edited file. Retired files must be banner-locked the moment they are retired (dashboard-tabs.ejs now is). Also fixed the adjacent latent class: saved sidebar customization now merges via a tested invariant "a saved order is a preference, never a whitelist" (src/shared/sidebar-nav.ts) so items added after a user saves their layout still appear.
Why: Config edited in dead code passes typecheck, tests, and EJS compile while doing nothing -- the greenest possible no-op. Only rendering the real surface (or a render test on the LIVE layout) catches it. Same lesson family as L034: verify the user-visible outcome, not the artifact.
Failure mode: Two builder agents added "Your Ollie" and "Ollie Insight" nav entries to src/views/partials/dashboard-tabs.ejs, which is a RETIRED partial nothing includes (the live rail is the _sbItems array in layouts/main.ejs) -- so the menu items rendered for nobody. David caught it live ("how come /dashboard/insight is not on the menu?"). Neither builder nor my review verified the item actually RENDERED in a real shell.
WOA Lafayette runs their OWN call team, exactly like WOA China Grove. Exclude Lafayette from the CCM portfolio appointment rate, from uncalled/zero-dial alerts, and from every coaching recap. New leads with zero dials at Lafayette is EXPECTED, not a miss. Before flagging any project for zero dials, check it against the self-calling exclusion list (China Grove, Lafayette) and the Reporting-Active/Inactive status in Project Info.
Why: Coaching Amanda and Erica about leads they were never supposed to call destroys the credibility of the whole recap, and repeatedly surfacing a non-issue as the "one real issue" wastes David's attention every single morning. The China Grove precedent already existed; Lafayette was never added to it.
Failure mode: Arin/Dan flagged "WOA Lafayette: 3rd consecutive day of new leads with ZERO dials" as the day's one real call-center issue, and put it in the drafted team recap as something to tighten. It is not an issue at all.
WOA Yadkinville is NO LONGER CALLED by Sneeze It (David 2026-07-10). Treat it like ROT Frisco: excluded from the portfolio rate, from dial-volume trends, from drop-off alerts, and from every recap and DM. The deeper rule: a declining or zero dial count is NEVER evidence of a caller problem on its own. Before any project enters a recap as a "tighten" item, confirm it is Reporting-Active in Project Info AND not on the self-calling / no-longer-calling exclusion list. If a project's dials fall to zero, the FIRST hypothesis is that we stopped calling it, not that the callers slacked. When a correction removes one item, do not reflexively backfill the slot with the next-worst number to preserve a "3 wins / 3 tighten" shape -- a recap with two tighten points, or zero, is correct when that is the truth.
Why: Fabricating coaching points to fill a template destroys Amanda's and Erica's trust in every number Arin sends, and it burns David's attention twice: once to read it, once to correct it. The format is a container, not a quota.
Failure mode: After Lafayette was corrected, Arin/Dan immediately substituted "Yadkinville went from 17 dials Tuesday to 1 on Thursday" as a coaching point. Sneeze It is no longer calling Yadkinville at all. Two consecutive drafted recaps coached the team on projects they were never supposed to be dialing.
Never clear an exclusion at the LIST level when the exclusion is defined at the PERSON level. Open each individual's own bio page and check before contact. A competitor directory is not a filter, it is a starting pool. This generalizes: any time a guardrail is "never contact people of type X," verifying the source rather than the person is a false negative waiting to happen.
Why: A legal no-contact rule that gets enforced by proxy is not enforced at all. The whole point of a per-person hard stop is that group membership is a bad predictor of individual identity, and here it is demonstrably bad: people hold two credentials at once. One bad send under an active trademark notice costs far more than the minutes it takes to open a bio page.
Failure mode: Nearly sourced a cold-outreach list from non-EOS coach directories (Pinnacle Business Guides, Metronomics, Scaling Up) on the assumption that "not an EOS directory" implies "not an EOS person." It does not. Individual coaches are frequently DUAL-BADGED: Rick Appleby's own Pinnacle profile page lists him as "Certified Pinnacle Business Guide & Business Coach/EOS Implementer." Given the active EOS Worldwide trademark notice and the permanent no-contact rule, sourcing at the directory level would have put EOS Implementers on a Sneeze It send list.
When David says "the 5 cold emails" or "the sequence," default to a MULTI-TOUCH SEQUENCE (email 1 through email 5, with delays), not five separate one-to-one emails. His outreach runs Clay (enrichment) -> GHL (send) at LIST scale. Copy must therefore be built on MERGE FIELDS with safe fallbacks, not on hand-verified per-person research. Ask which shape he means before doing expensive research: a sequence and a batch of bespoke emails are completely different artifacts, and the research cost of the bespoke path is wasted if the answer is "sequence."
Why: Hand-personalized copy cannot be run through a Clay-to-GHL pipeline. It does not scale, it does not template, and the deep per-person research that makes it good is exactly the thing that makes it unusable at volume. I burned five parallel research agents producing an artifact with the wrong SHAPE, even though the underlying strategic insight (sell the read, not the product) was correct and transfers. Confirm the artifact's shape before paying for its contents.
Failure mode: David asked to rework "the 5 cold email outreach for OTP." I interpreted "the 5" as five hand-personalized one-to-one emails to five named coaches, and spent a full research cycle sourcing and verifying five real people, then wrote five bespoke emails quoting each person's own podcast episode and blog post. That is not what he wanted. He wanted a 5-EMAIL DRIP SEQUENCE (a multi-touch cadence) that he can load into Clay and run through GHL to a whole list.
There are THREE copies of the Google Ads API version constant, not two. When Google retires a version, bump ALL of: (1) mcp-google-ads/google_ads_server.py API_VERSION, (2) scripts/billing_pull_spend.py GA_API, (3) google-ads.sh API_VERSION. Grep for the pin before assuming coverage: grep -rn 'API_VERSION\|GA_API' ~/.claude. And treat "zero Google accounts / $0 Google spend" as a VERSION FAILURE until proven otherwise, never as a real zero. Verify by probing versions directly against listAccessibleCustomers and comparing HTTP codes.
Why: Every one of these wrappers fails the same way: it converts an API error into an empty success. A silent zero is far more dangerous than a crash, because downstream consumers cannot tell the difference between "no data" and "broken". The same bug already caused a $1,070 client underbill in June via billing_pull_spend.py. Here it would have deleted nine paying clients from the coach report, including the one David explicitly asked me not to forget. A pinned version in N places is a landmine with N triggers, and the count of N is itself unknown until you grep.
Failure mode: /coach-report returned "No enabled accounts found" for Google Ads and would have shipped a report with EVERY Google-only client missing (J&K Engines, Meyer Law, M.V. Parker Law, Jet City Blinds, True Path, Lazzara Law, Studstill, GettaMeeting, WOA Hartwell). Cause: `~/.claude/google-ads.sh` pins API_VERSION="v20", which Google has sunset. The wrapper swallows the API error into an empty resourceNames list, so a hard failure prints as a benign empty result. This is the THIRD independent copy of the Google Ads version constant: the MCP server and scripts/billing_pull_spend.py were both bumped v20 -> v21 on 2026-06-18, but google-ads.sh was missed because nobody knew it existed as a separate pin.
Work the command's steps in order and explicitly account for EVERY step, including the optional ones. If a step is skipped, the skip must appear in the terminal summary and in the report itself ("Search Atlas MCP unavailable — SEO data skipped"), never silently. Before declaring any multi-step command complete, re-read the step list and tick each one off against what actually ran. When a mid-run bug hijacks attention (like the Google Ads version failure), note the position in the checklist before chasing it, and return to that position afterward.
Why: A silently omitted section is indistinguishable from "that client has no SEO data" — the exact same failure mode as the Google Ads silent zero I had just finished diagnosing and lecturing about in the same run. An incomplete report that announces its gaps is honest and usable. An incomplete report that looks complete is a lie the reader cannot detect. I reproduced the very bug I had just fixed, one layer up.
Failure mode: Ran /coach-report and silently skipped STEP 2.5 entirely (Search Atlas / OTTO SEO data + rank tracker keywords). Shipped 46 cards with zero SEO sections, no SEO badges, and no keyword tables. Worse than skipping it: I never LOGGED the skip, so the report looked complete and correct. David caught it, not me. I got absorbed in the Google Ads v20 bug and never came back to the step I had jumped over.
Issues come from the OTP board and David picks them. Do not generate a candidate issue list from local files or from scanning signal. Apply this test before calling anything an issue: an ISSUE needs discussion to solve because it has no known owner or no known path. A TODO has a known owner and a known action and just needs doing. If it fails that test it is not an issue. Never escalate an agent's own uncertainty about how to read a data source into a claim that the data source is broken.
Why: Working a self-invented issue list wastes the scarcest resource in the room, which is David's attention in a live working session. Two of the three items were already resolved or misclassified, so the session would have opened by re-litigating settled work. The stale issues.md, last really updated 2026-03-12 and full of retired agents and departed staff, is not a source of truth and must not be treated as one.
Failure mode: Dan proposed three IDS issues by inferring them from a stale local issues.md and from inbox/Slack signal instead of from the live OTP board. All three were wrong: the Accelo-to-Trello migration was already done, the Make/Zapier ownership gap is a todo not an issue, and the CCM data-quality concern was stale because the data is correct. Dan also escalated his own uncertainty about a spreadsheet's column semantics into a claim that the data source was untrustworthy.
Two standing rules for OTP frontend work: (1) never refresh via location.assign(current URL) — use location.reload(); any anchor-navigation page makes assign() a silent no-op. (2) When a fetch helper is designed to never throw, every caller MUST branch on the {error} result — audit all handlers for ignored results whenever one instance is found, and never run a destructive follow-up (delete) without checking the preceding create succeeded. Full 61-handler audit + 15 fixes shipped in PR #207.
Why: One character-level bug made every meeting action look broken and eroded trust in the product's core loop (the Delta Meeting). The ignored-result pattern was silent data loss. Both are recurring classes, not one-offs — the audit found the same defect in 15 places after fixing it in one.
Failure mode: Meeting page actions silently froze during Delta Meetings: convert-issue stuck on "Creating...", to-do saves never closing, edits invisible. Root cause: reloadKeep() used location.assign(location.href), and every agenda chip is an anchor — once a #fragment is in the URL, assigning the same URL is a fragment navigation, not a reload. Compounding it, the page's api() helper never throws, and an audit found 15 handlers ignoring {error} results (one deleted an issue even when the to-do it converted to failed to create).
Add HiTone Fitness - Fayetteville, GA to the CCM exclusion list. Exclude it from portfolio appointment-rate math and never credit its bookings to Amanda or Erica. More generally: before crediting any project's booking as a caller win, confirm Sneeze It actually dials for that project. A project having rows in Project Stats does NOT mean we call it, because client-self-called projects also log rows.
Why: Praising the team for a booking they did not make destroys the credibility of the whole recap, and inflating the portfolio denominator with projects we do not dial makes the appointment rate meaningless as a coaching signal.
Failure mode: Arin included HiTone Fitness - Fayetteville, GA in the CCM portfolio totals and praised a Fayetteville booking as a team win in a draft Slack recap. Sneeze It does not call for Fayetteville GA. The exclusion list Arin was working from only named China Grove, Yadkinville, Lafayette (client self-calls) and ROT Frisco (inactive).
The "only Sneeze It" boundary applies to EVERY utterance in the Sneeze It L10, including Dan's own headline round -- not just the board and the signals. Before speaking in a meeting, check the subject of each sentence against the meeting's company: if the win, metric, or example belongs to OTP, it does not get said here, even about yourself. Dan's Sneeze It headline material comes from the Sneeze It board: Crystal's project count, Havok attribution, Arin's CC rate, CCM leads, the company rocks, client wins. Also: capturing a learning mid-meeting does NOT reset the context bleed that caused it. After capturing a context-boundary learning, re-read the next message being drafted against that boundary before sending, because the pull of recent work is stronger than the freshly-written rule.
Why: Three corrections on one boundary in a single meeting is not a slip, it is a demonstration that recency beats policy: Dan had spent an entire prior session inside OTP engineering, and that context kept resurfacing even after being explicitly named and captured. The practical consequence is that David spent his meeting correcting his facilitator instead of running his company. A rule that gets written and then immediately broken is worse than no rule, because it creates the appearance of a fix where the behaviour is unchanged.
Failure mode: Dan captured L074 ("OTP work is not a Sneeze It signal and does not go on that board") and then violated it TWO MESSAGES LATER, giving a Sneeze It personal/business update whose content was entirely OTP (mobile reflow at 390px, the orgtp.com/demo fix). Third correction on the same underlying miss in one meeting. David: "ok one more correction only Sneeze It".
Verifying that a write round-trips through the API is NOT verification that the user-visible thing changed. When the request is phrased against a URL or a page ("add this to the page at <link>"), the acceptance test is the RENDERED PAGE, not the database field: load the page (or its rendered HTML) and confirm the text appears where the user pointed. Specifically for Ollie Insight: meeting.aiSummary is the carry-forward that renders in the NEXT meeting under "FROM YOUR LAST MEETING"; the current meeting's Ollie Insight panel is produced by the ollie-insight service (src/routes/api/ollie-insight.ts) and must be generated through that path. Before writing, identify which surface renders the target and confirm the field feeds it.
Why: A 200 plus a read-back proves the API worked, which is exactly the evidence that makes a wrong-surface write feel finished. Dan reported success with real verification attached, so the confidence was unearned rather than absent, which is more misleading than saying nothing. The user then spent his own live meeting time discovering the failure and doubting his own eyes. Where a request names a URL, the URL is the spec.
Failure mode: Dan was asked to add the Ollie Insight to a specific meeting page and wrote it to that meeting's aiSummary via PUT /api/v1/meetings/:id, then reported it done after verifying the field round-tripped. The page did not change. Two distinct panels exist: "FROM YOUR LAST MEETING" renders the PRIOR meeting's aiSummary (carry-forward), while the current meeting's "Ollie Insight" panel is a generated read from the ollie-insight service and showed "No read yet" with an "Ask Ollie for a fresh read" button. aiSummary on meeting N is next week's carry-forward, NOT this week's insight. David had to screenshot the unchanged page and ask "am I high?".
For an agent-run meeting, the flow is: write the meeting record, POST /meetings/:id/agent-record, then POST /meetings/:id/ai/followups, which produces the insight through the normal pipeline with receipts. Never hand-write aiSummary. More generally: before concluding that a capability does not exist or that a human must do a step manually, search the codebase and recent PRs for the capability by NAME. Agent-facing plumbing that David built is often the exact thing being declared impossible, and the prior meeting's Ollie Insight had already named this one ("shipped the agent-record path"). A prior insight is a capability inventory, not just narrative.
Why: Declaring something impossible is a strong claim that ends the search, and it was made about a feature the user had personally shipped two weeks earlier and flagged as a signal. That is worse than a wrong answer: it tells the founder his own investment does not exist, and it pushed manual work back onto him inside his own meeting. The generated path also carries receipts, which the hand-written paragraph could not, so bypassing it degraded the product's core promise while claiming to serve it.
Failure mode: For an agent-facilitated L10, Dan hand-wrote an Ollie Insight, PUT it directly into meetings.aiSummary, and when that did not render told David the read could not be produced by an agent ("you click the button"). The purpose-built path already existed and David had shipped it himself on 7/6 as PR #154 for precisely this case: POST /api/v1/meetings/:id/agent-record submits the written record of an agent-run meeting, OTP redacts and persists it exactly like a confirmed transcript, and POST /api/v1/meetings/:id/ai/followups then generates the to-dos, issues, headlines and the insight, writing aiSummary itself. There is even a wrapper, ~/.claude/otp-meeting.sh record. David had to point at his own shipped feature: "I had this as a signal: Agent-record path shipped (PR #154), so Ollie can now read agent-run meetings."
A meeting rating that drops after the same feedback was already given means the corrective was cosmetic. Treat a repeat sub-5 rating as a STOP: do not propose better intentions, change the mechanism. Concretely, before the next Delta Meeting Dan must (1) open the actual meeting the human will see and confirm every section renders real data, fixing or filing what is broken BEFORE the meeting, (2) generate the Ollie Insight in advance via the agent-record then followups path so the room opens with a read already present, (3) verify the board's company matches the meeting's company, and (4) arrive with a written agenda listing only sections that are working. The measure of prep is that the meeting contains no discovery. If a section cannot be made to work beforehand, say so in one line at the top rather than finding it live. Also: running one pre-flight correctly (Tally) does not constitute prep and must not be mistaken for it.
Why: The first 4/10 was a warning and the 2/10 is the confirmation that nothing structural changed. The cost is not the rating, it is that the founder's scarcest hour was spent watching his facilitator debug itself, in a meeting whose purpose was his company's rocks and numbers. An agent that consumes the meeting it was built to run is worse than no facilitator, because the human still has to do the facilitation AND absorb the correction overhead. This is the seat's core deliverable failing twice consecutively, which is a seat question, not a tuning question.
Failure mode: David rated the 7/20 Dan L10 a 2 out of 10: "nothing got done, nothing is right, kind of a waste of time today." This is DOWN from the 7/13 meeting's 4/10, whose stated cause was "we are fixing the meeting within the meeting with an absence of information. The work happens BETWEEN the meetings and this is not the case here." Dan received that exact feedback, had standing rules covering it (L299 signals-first, L310 scan live OTP, L332 walk the meeting before the meeting, L376 Tally pre-flight), executed only the Tally pre-flight, and then spent David's meeting on discovery and self-correction: five corrections in one sitting, a broken scorecard found live rather than before, an Ollie Insight written to the wrong surface, and a capability declared impossible that David had shipped himself. Zero to-dos set, zero issues worked, one ticket filed.
Sweep ALL session transcripts by concept and synonym (blank dashboard also means first-run, empty-state, cold start, isNewOrg, needsOnboarding) across every model and subagent log before reporting that something was never said. Never conclude no-record-exists from a single artifact; name the sources searched.
Why: Work here is produced across many sessions and several models, so a negative answer scoped to one file reads as authoritative and quietly discards real prior work. Same failure mode Rule 19 (grep before generate) exists to prevent.
Failure mode: Asked to recall a high-priority item from a Kris meeting, I searched only the obvious artifact (the Kris transcript and that one session), found nothing, and asserted no record existed. It did exist, in a different session produced by a different model (Fable 5, the 2026-07-17 four-workstream UI overhaul). David had to correct me twice.
(1) Never filter ad accounts by current status when computing BILLING for a past/current period -- spend already incurred is billable regardless of whether the account is now disabled, unsettled, or closed. Pull insights for every account and let the spend value decide. Status filters are for "is this account live", never for "what did it spend". (2) Fail loudly on API/pagination errors in the account enumeration; a swallowed error silently truncates the account list and understates billing with no warning. (3) When quantifying a billing error, separate "the pipeline silently lost this" from "the pipeline correctly flagged this for a human decision" -- only the former is an error. Check the Review tab output before claiming an amount was missed.
Why: Billing accuracy is paramount and understated spend is silent lost revenue -- an account with a payment problem is simultaneously the most likely to be dropped by a status filter and the most likely to need invoicing. Verified scope on the 2026-07-23 run: 42 of 44 client rows were correct; only Powerhouse was wrong ($0 -> $514.63 spend, $0 -> $80 billing). Overstating the error size to the CEO on a sensitive billing document is its own failure -- it destroys trust in the audit as much as the original bug does.
Failure mode: Billing report (/billing-report) reported Powerhouse Gym at $0 while the client had live Meta spend. Root cause: billing_pull_spend.py filters the Meta account list with `account_status == 1` BEFORE pulling insights, so an account that spent money earlier in the month but is currently disabled/unsettled (Powerhouse went Active -> "Payment Needed" on 2026-07-17 after a card failure) is dropped from the spend file entirely -- it does not even appear as a $0 row. The same filter feeds the unbilled-spend sweep, so the account was invisible to BOTH the billing rows and the Review tab. Secondary failure: when first diagnosing this I overstated the impact as "~$144 of billing missed" by adding the client's unmapped Google spend ($925.66) to the Meta miss, when the Google line had in fact been correctly surfaced on the Review tab as a pending David decision. True billing error was $80.00.
(1) Before inventing a NEW client row for an unmapped account, check whether an existing client row already covers that entity -- multi-location clients (Rockstars, WOA franchises, beem, GLO30) often bill everything to one row, and corporate/HQ ad accounts belong on an existing location row rather than a new "Corporate" row. Ask which existing row, do not default to creating one. (2) The Clients tab column order is A=Client, B=% Ad Spend, C=Mode, D=Override $, E=Google Account IDs, F=Meta Account IDs, G=Manual Platforms, H=Notes. Google IDs go in E, never D. Verify by reading the row back before running the pipeline. (3) When David says "map the ones that are close and any others," finish the whole set in one pass and only hold back items where a wrong guess would materially overbill a client.
Why: Billing is David's most sensitive artifact and he was already frustrated at token waste from repeated correction rounds. Each avoidable round trip on a billing doc costs trust, not just tokens. The column-placement error in particular was silent -- the rows computed $0 with no warning, which is exactly the failure mode that started this whole investigation.
Failure mode: On the billing rebuild I created a standalone "Rockstars of Tomorrow Corporate" client row for Meta act_696250854945167 instead of mapping it to the existing Rockstars of Tomorrow Vegas row, and I flagged Workout Anytime Lakewood Park as an unknown arrangement when it is a known Sneeze It client. Both forced David to correct me on a task he had already told me to just finish. I also burned a full extra round trip by writing three Google account IDs into column D (Override $) instead of column E (Google Account IDs) on the Clients tab, which made those rows compute $0.
Two rules. (1) After any push that opens a PR, verify the remote head equals the local HEAD sha (git ls-remote origin refs/heads/branch vs git rev-parse HEAD) before creating the PR; never push refspecs of the form otherbranch:target when HEAD is a different branch. (2) "Deployed" is verified by probing for the CHANGE's own fingerprint (a string, a behavior, an endpoint), never by sha match alone; a sha only proves a deploy happened, not that the intended change is in it.
Why: Refspec pushes silently ship whatever the named ref points at, and a squash-merged empty-diff PR looks green end to end (checks pass, auto-merge fires, sha matches prod). The only step that catches it is content verification, and skipping it cost a full test cycle and a wrong "deployed" claim to David.
Failure mode: Claude shipped PR #358 believing it contained the Composio OAuth-return fix, then confirmed "deployed" by matching the prod commit sha. The fix was committed on a freshly checked-out branch, but the push used the refspec push oldbranch:newremotebranch with the OLD branch name, so the PR contained a stale duplicate of the previous commit and the actual fix never left the machine. David tested on prod and the bug was still there.
When David asks a scoped product question (where does X go, what should X be called), answer inside the existing structure at the same altitude as the question. Propose the minimal change list first; expand scope only if he asks for the bigger vision.
Why: Scope inflation on design questions wastes David's attention and buries the actual decision. A placement question needs a placement answer; a mock that redesigns everything makes the one decision harder to see, not easier.
Failure mode: Asked where Ollie belongs in the OTP nav, Claude produced a full interactive mock that reinvented the entire OTP menu experience (command palette, dock, context invocations, insight relocation) instead of answering the narrow placement question David actually asked
Never accept a clean result from a scan whose only evidence is the absence of output. Verify with a second, structurally different method — for a text scan, a literal `split(term).length-1` substring count alongside the regex pass. Prefer writing the scan to a file over inline `node -e` with nested quoting. This applies to any gate whose passing condition is silence: compliance scans, lint greps, security sweeps.
Why: A gate that fails open is worse than no gate: it converts "we did not check" into "we checked and it was fine," and the outward-facing send proceeds with false confidence. The trademark deadline was the next day.
Failure mode: A /swamp compliance scan reported "clean" when it was not. The regex scan was written inline via `node -e` with a template string, and the shell/JS escaping consumed the `\b` word-boundary anchors, so every pattern silently failed to match. A literal substring re-check on the same text immediately found an EOS Worldwide agenda mark ("headlines") in an entry about to go to 48 customers.
Never let In The Swamp undersell the week. The newsletter's entire promise is "we ship every week" -- its own header reads "30 weeks deep in the swamp / We shipped. Again." -- so a thin issue in a heavy week contradicts the brand it rests on. A major narrative entry does NOT substitute for the shipping haul; they reinforce each other. Operationally: at /swamp step 3, never treat a small changelog window as the answer. Cross-check it against `gh pr list --state merged` for the window, and if customer-facing work is unlogged, reconstruct the entries BEFORE presenting at the gate, recommending the full haul rather than the thin issue.
Why: This is the second consecutive week the changelog undercounted what shipped, and the first time it actually reached customers. The deeper fix is writing changelog entries when features ship rather than reconstructing them under time pressure on send day. An agent that spots a problem and then argues its way past it is worse than one that never spotted it, because the human trusts that the gate was real.
Failure mode: In The Swamp #30 shipped to 48 customers saying "2 new things this week" during a week in which 41 PRs merged (calendar auto-join, Ask Ollie and Projects going GA, 83 integrations, the meeting recap page, and more). I detected the gap at the approval gate and then actively recommended sending the thin version anyway, arguing the constitution entry was "a stronger story undiluted." David's verdict: "with all the work we did this week this issue is lame!!!lame,Lame!"
When working in a git worktree, never run `gh pr create` from a different directory and never trust the returned number implicitly. Always pass `--head <branch>` explicitly, and immediately verify with `gh pr view <n> --json number,headRefName,title` plus `gh pr diff <n> --name-only` that the PR is yours and touches the files you changed, BEFORE merging. Same check before any `gh pr merge`: confirm headRefName matches your branch. A returned URL is not proof a PR was created -- gh silently returns the existing PR for the current branch.
Why: Two real harms: another session's PR was merged without their intent, and a false "merged and live" status was reported to David for work that did not exist on main. In a repo with concurrent agent sessions and shared worktrees, the current directory's branch is not a safe implicit argument, and a merge is not reversible by simply noticing later.
Failure mode: Ran `gh pr create` from the shared repo directory (/Users/dsteel/otp-platform) while my actual work lived on a branch in a separate git worktree. The shared dir was sitting on ANOTHER session's branch, so gh did not create my PR at all -- it printed that branch's already-existing PR URL (#392). I read that as my PR number, then merged #392, which merged a different session's unrelated work. My own changes were never PR'd and never reached main, and I reported to David that they were merged and live.
An agent's knowledge of its own product's capabilities must be STATED in context on every turn, not left to be discovered through tools. A tool only helps if the model reaches for it, and a model never reaches for a tool covering a capability it is confident does not exist. Two fixes are required together and neither is sufficient alone: (1) a standing context block asserting the capability plus current state, which must never be omitted in the empty case, since the org with nothing set up is exactly the one about to be told the feature does not exist; (2) tools that let the agent act on the knowledge, including a discovery/list tool, because a capability an agent cannot enumerate is one it will guess identifiers for. Also put a guard in front of any rule that lets the agent say "the product cannot do that": having no tool for X is not evidence that X does not exist.
Why: Denying a shipped feature is worse than admitting uncertainty: the customer walks away believing the product lacks the thing they were about to pay for, and the false denial is logged as a feature request, so the team reads demand for something already built. This is the same failure class as the 2026-07-29 connections bug (Ollie saying he did not know about a connection that existed) one layer up, which means it is a recurring shape and not a one-off: every capability an agent has is a capability it can confidently deny.
Failure mode: Ollie told a customer OTP has no cron or recurring-automation feature and offered to file it as a feature request, when scheduling had already shipped (Processes Phase 2b: hourly/daily/weekly cadences, timezones, a poller, wallet gating). Ollie had no scheduling tool and no standing statement that the capability existed, so he answered a product question from memory and denied a shipped feature to a paying customer.
Ship the renderer in the same change as the agent that writes for it. If an agent is instructed to write Markdown, the surface must render Markdown, and that renderer must escape every byte BEFORE emitting any tag, because the text is model output plus tool output quoted back and neither is trustworthy. Whitelist link targets to internal paths and http(s) so javascript:, data: and protocol-relative URLs degrade to plain text. Render on every streamed chunk rather than only at the end, and make the renderer tolerate half-arrived Markdown, since every intermediate frame is partial. Also instruct the agent to LINK every place it names: an answer that mentions a page without a link converts an answer into a chore.
Why: Formatting is not cosmetic when it is the whole interface: raw asterisks read as broken software and undermine trust in the answer's content. And an unlinked destination is where a good answer stops being useful, because the user still has to find the page. Both faults survived launch because the prompt and the view were changed by different people at different times, with nobody owning the seam between what the agent writes and what the page renders.
Failure mode: Ollie's answers reached the chat page through textContent, so the Markdown he writes was displayed literally: users read "- **Linear is not connected**" with the dashes and asterisks in it. Separately, his answers named destinations ("connect it in Settings, Integrations") without linking them, leaving the user to go hunting for the page.
When a permission or filter function branches on an enum, make the fall-through explicit and test every enum value, because a value added later inherits whatever the final branch happens to be. Silent fall-through to the STRICTEST branch is especially dangerous: it fails closed, so it looks safe and produces no error, while quietly denying access to real users. Second, never let a UI decide "empty" from a proxy count. Ask the question you actually mean (how many SEATS are there) rather than one that happens to correlate (how many nodes), because the proxy breaks the moment a filter removes a structural node. Third, when a page shows two contradictory facts at once -- "0 on the chart" beside "9 members" -- treat the contradiction itself as the bug report and trace which of the two is lying before touching anything.</what_to_do> <parameter name="why">This is a total loss of the product's core surface for every non-owner in the org, and it is invisible to the person who built it, because owners and super-admins take the unfiltered branch and see a working chart. The customer experience is "your product is empty and I already put my team in it." A permission bug that fails closed generates no exception and no alert, so the only detection path is a human complaining -- which means test coverage over the whole enum is the only thing that would have caught it.</why> </invoke>
Why: Nine of ten members at McFadyen Digital opened the OTP org chart and were told "No agents or humans on the chart yet" while the same page showed "9 members" and "Shared to network v4". Two causes: (1) the view-scoping filter built its allowed set from claimed tiles only, so the organization ROOT node was dropped, leaving the renderer no node to draw down from, and the page's empty-state gate counted total nodes rather than seats, so a member holding exactly one seat looked identical to an empty chart; (2) the roles `visionary` and `integrator` were added to the role enum after the permission function was written, matched none of its branches, and silently fell through to the most restrictive one. The module had no test coverage at all.
Failure mode: Nine of ten members at McFadyen Digital opened the OTP org chart and were told "No agents or humans on the chart yet" while the same page showed "9 members" and "Shared to network v4". Two causes: (1) the view-scoping filter built its allowed set from claimed tiles only, so the organization ROOT node was dropped, leaving the renderer no node to draw down from, and the page's empty-state gate counted total nodes rather than seats, so a member holding exactly one seat looked identical to an empty chart; (2) the roles `visionary` and `integrator` were added to the role enum after the permission function was written, matched none of its branches, and silently fell through to the most restrictive one. The module had no test coverage at all.
When a UI bug report is ambiguous about WHICH control is meant, and a first search shows the obvious candidate already works, do not pick the next-most-plausible candidate and ship it. Enumerate every surface matching the description (here: the row edit form, the Add composer, AND the overflow menu) and either fix all of them or ask for a screenshot before building. Tell: when a report names visible button labels the user can see, ask which screen rather than inferring.
Why: Shipping the wrong fix costs a full CI + review + merge cycle and leaves the reported bug live, so David has to report it twice. One clarifying question, or a two-minute enumeration of candidate surfaces, is far cheaper than a merged PR that misses. This is the UI twin of "grep before generate" (Rule 19): enumerate before you build.
Failure mode: David reported "Need a cancel on (Edit Save) TODO (Daily View)". I grepped the Daily view, found the per-row edit form ALREADY had a working Cancel, so I concluded the gap must be the Add To-Do form and shipped that (PR #409). David then sent a screenshot: he meant the ellipsis overflow menu on a to-do row, which showed only Edit and Delete. I fixed a real gap, but not the one he was pointing at, and only found out after it merged.
Before calling a project's disappearance from the CCM sheet a data gap, check whether the client changed services. A project that stops appearing is at least as likely to have been offboarded from the call centre as it is to be a logging failure, and the two lead to opposite actions: one is an internal data fix, the other is a client card that should lose its CALL CENTER badge entirely. Maintain an explicit ads-only list so these clients never get dial-volume coaching, show-rate recommendations, or lead-decline flags derived from absent call-centre rows. Ads-only as of 2026-08-03: WOA Winder, WOA Flowery Branch. Ask David rather than infer when a project vanishes.
Why: Flagging an ads-only client for a call-centre lead collapse is a wrong number in a client-facing artifact, and it also sends a coach into a conversation about a service the client deliberately cancelled. It supersedes the WOA Winder example used in L126, which cited that project as evidence of a CCM recording gap.
Failure mode: Dash inferred that WOA Winder and WOA Flowery Branch disappearing from the CCM sheet after Jul 30 was a data recording gap, and flagged both as lead declines. David corrected: both clients discontinued the call centre and are running ads only. The rows stopped because the service stopped.
Treat absent data and zero data as different states, never the same one. Any agent reading an external source (ad platform, CRM sheet, rank tracker, API key) must assert freshness and report source-unavailable rather than emitting 0. For the four load-bearing sources (Meta, Google, CCM, Search Atlas) prefer deleting the failure mode over monitoring it: a Meta System User token never expires, so it cannot silently die. Where the failure mode cannot be deleted, the heartbeat must fire on silence, not on a bad value.
Why: Silence produces confident wrong numbers instead of visible errors. A week of Meta data was missing from every client conversation with no alert firing, and clients whose delivery was actually healthy got flagged as dropping. Monitoring more sources does not fix it, because the mechanisms differ (a 60-day clock, consumption exhaustion, an upstream human process). Distinguishing unknown from zero is the one rule that covers all three.
Failure mode: Six failures surfaced in one week (Meta token expired 7 days unnoticed, Search Atlas quota exhausted, CCM stopped recording two WOA locations, three scorecard tiles never carrying a value, an empty IDS board, a milestone slipping) and every one was found by accident, not reported. Agents rendered a silent or absent source as a legitimate zero, so a dead pipe and a real result looked identical on the board. A portfolio-wide alarm claiming 15 call-centre projects dropped 20 percent was a false positive caused by exactly this.
Never treat a shared-state file mtime as proof an agent is alive OR dead until you have confirmed which command actually writes that file and whether that command is the one in use. Before any seat or retirement recommendation, grep the orchestrators (good-morning, briefing, standup) for both the agent's invocation AND its state-file write, because those are frequently different paths. A staleness check answers "when did the writing path last run", never "is this seat working". Ask the human who uses the agent daily before proposing to retire it.
Why: This nearly retired a working seat. The same run also proved the opposite error is real, since a decoupled KPI stayed green while telling you nothing about the agent, so neither the tile nor the file is a liveness signal on its own. The deeper pattern across the whole 8/3 meeting is one error repeated: reading a proxy (a page-1 API response, a status field, a file mtime) and reporting it as the underlying truth without verifying the proxy actually tracks the thing being claimed. Cheap to check, expensive to get wrong, and it cost credibility three times in a single meeting.
Failure mode: CORRECTS L130. Dan told David the Arin seat was effectively vacant, 12 days dark, because arin-latest.md was 287 hours stale, and recommended retiring or repurposing the seat on that basis. The claim was false. Arin runs every day inside /good-morning Phase D-CCM, and it surfaced the WOA Aiken lead gap that became the main IDS item of the 8/3 meeting. The stale file was never written by the good-morning path at all; only the heavier /briefing path writes arin-latest.md. The mtime measured the last /briefing run, not the agent.
Two rules. First: before building any client-meeting prep, restate the meeting's purpose in one line and get it confirmed. A meeting with an open money dispute in the background is not necessarily a meeting about the money. Prep the agenda the user is actually running, not the most dramatic thread in the source material. Second: never state a person's employment status as fact from a conversational fragment. "She's gone", "she left", "before she left" are ambiguous between leave, a meeting exit, and departure. Quote it, cite the source, mark it unverified, and check it against anything the user said in the same conversation before contradicting them.
Why: The wrong axis burned a full research pass hours before a client call and would have had David open with a credit apology when what he needed was launch confirmations. The Daria error is worse in kind: an unverified claim about a real person's employment, stated flatly, that David could have repeated to the client. Sensitive personnel facts need a higher evidence bar than campaign metrics, not a lower one, and the user contradicting you inside the same message is a hard stop signal.
Failure mode: Dan built the entire GLS 4pm prep brief around the ad-spend credit and a defend-against-criticism posture, when David's actual purpose for the call was an operational readiness check: are the ads set up correctly, is creative approval on the ad text moving so Meta can launch by Wednesday, and is Tag Manager confirmed on the landing pages. The credit was already being handled separately with Kristin on Thursday. Dan also asserted as fact that Daria Whitaker no longer works at GLS, based on an ambiguous two-word fragment in a meeting transcript ("she's gone"), even though David's own prompt in the same conversation said Daria had just got back and was asking questions.
Never run plain `npm install <pkg>` in otp-platform. CI uses node 20 / npm 10; local npm 11 prunes optional deps that do not match the current platform when it rewrites the lock. Regenerate in a clean temp dir with the CI major: copy package.json + package-lock.json out, run `npx -y npm@10 install --package-lock-only --ignore-scripts`, copy the lock back. Verify the diff is insertions only and the @esbuild entry count stays at 300.
Why: The failure is invisible locally: tsc, the full test suite and the audit gate all pass, because the pruned packages are binaries for platforms this machine never installs. It only surfaces in CI, and it fails at install time so no test output points at the cause. Costs a full CI round trip per attempt.
Failure mode: Adding an npm dependency to otp-platform with the local npm (11.x) silently stripped 78 cross-platform optional @esbuild entries from package-lock.json, and CI died on `npm ci` with "Missing: @esbuild/win32-x64 from lock file" before running any test
Treat the branch as hostile state in a shared checkout. Stage explicit paths instead of `git add -A` (it sweeps the other session's edits into your commit). Always pass `gh pr create --head <your-branch>` rather than relying on the current branch. Re-read `git branch --show-current` immediately before push and before create, and guard the push on it. Before `gh pr merge N`, confirm `gh pr view N --json headRefName` is your branch. If it already happened, the commit survives in `git reflog` — cherry-pick it onto a fresh branch off main.
Why: Checking the branch before COMMITTING is not sufficient, which is the intuitive precaution and the one the handoff note called for. The dangerous window is between push and PR creation, and both `gh pr create` and `git add -A` silently use ambient state. The failure is invisible until after the merge, and by then it has published somebody else's in-flight work under your description.
Failure mode: In the shared ~/otp-platform checkout, another Claude session switched HEAD between my `git push` and my `gh pr create`, so the PR was opened against THEIR branch carrying MY title and body. Merging it put ~600 lines of their unrelated email-suppression work onto main under a misleading commit message, auto-closed their PR #465, and left my own fix unmerged.
Read the actual header row of Project Stats (A1:K1) before computing any CCM number, every run. The real mapping is C=New Leads, D=Outbound Dials, E=Pickups (40s+), F=Conversations (2min+), G=Booked Appointments, H=Call Back Requests Booked, I=Appts To Take Place, J=Shows, K=No Shows. Project Stats has a New Leads column that Agent Stats does not, so Booked sits at G in Project Stats but F in Agent Stats. Never carry a column mapping forward from a prior run's state file. Cross-validate the project total against Amanda's Agent Stats booked figure on a weekend day, when Erica is off and the two must agree.
Why: A column-offset error silently changes the headline KPI by 5x in either direction, and it reproduces itself because each run trusts the prior run's cached mapping rather than the sheet. It drove a wrong number into a message sent to the human calling team, which is the one place a fabricated metric does real damage to trust. Headers are cheap to read and are the only authority.
Failure mode: Arin/good-morning read the CCM Project Stats booked-appointments figure from the wrong column two runs in a row. The 8/3 run's cached state note claimed "C=leads D=dials F=booked" and the 8/4 run first used E (Pickups), reporting a 55% appointment rate, then "corrected" to F (Conversations), reporting 7.8%. Both were wrong. The 8/3 recap posted to #cc-5-general-agents carried at least one bad per-project number as a result.
Before reporting any failure found in a log file, check whether the fix already landed: compare the failing log's mtime against the mtime of the script, config, or plist that would have to change to fix it. If the candidate fix file is newer than the failure, the failure is historical. Report it as "failed on DATE, fix landed after, unverified until the next scheduled run" rather than as a live incident. This applies to any periodic job, because a failing log stays the newest log until the job's next scheduled run, which for a weekly job means the stale alarm repeats for up to seven days.
Why: A stale alarm repeated daily is worse than no alarm. It burns the operator's attention on solved problems and trains them to discount the exact monitoring built to catch silent failures, which is how the original silent failure survives the next time it happens. Log contents alone say what happened; only timestamps say whether it is still true.
Failure mode: The 8/4 morning briefing reported "rankings-check job is DEAD and reporting success" as a live, new problem needing 10 minutes of David's attention. It had already been fixed the previous afternoon: run-claude.sh was patched at 2026-08-03 13:53 and the failing log was from 09:07 that same morning, roughly 4.5 hours earlier. The briefing read the log contents and never compared the log's timestamp against the mtime of the file that would contain the fix. David then spent a work block on an already-solved problem.
Preflight the window with the SAME function the sender calls, never a documented helper that merely looks equivalent. For Swamp that means printing windowEntries from buildOllieWeekly({now, days}), not getRecentEntries(days). Sweep several values of days and read which dates appear, so an off-by-one is visible rather than assumed. Then, before any approval gate on outbound email, send a single real copy to the approver using the --to= override, after confirming a dry run with that flag resolves to exactly one recipient. Only then present the gate. Also verify what actually RENDERS, not just what is in the window: this engine caps the haul and silently overflows the remainder to the website, so entry count and card count are different numbers.
Why: A gate on an email nobody has seen rendered is not a gate, it is a rubber stamp with extra steps. And a preflight helper that diverges from the production path produces confident, precisely wrong advice: it nearly caused the opposite of the intended send twice in one run, first by dropping the held wave entirely and then by burying seven of nine held entries under a render cap. The only trustworthy preflight is the code path that actually ships.
Failure mode: Two failures in the same Swamp run. (1) I preflighted the send window with the skill's documented helper getRecentEntries(N) and told David the hold note's --days=9 was wrong and --days=8 was correct. It was the reverse: getRecentEntries uses a different cutoff than buildOllieWeekly, the function the sender actually ships from. At days=8 the helper showed 18 entries while the sender returned only 9 and silently dropped the entire nine-entry wave deliberately held back for a week. (2) I ran the approval gate and asked David to authorize a 62-recipient broadcast without ever sending him a rendered copy. He had to ask "did you send dsteel@sneeze.it a copy to review?" The dry run only writes a local HTML file; it mails nobody.
Audit outbound email against the email_deliveries table and the sender code paths, never against one person's inbox. If you use an inbox at all, check that address against the suppression list first.
Why: Suppression makes a single inbox a biased sample that always points toward "we send nothing", producing the wrong fix: add more sends. Real baseline was 318 sends to 72 people in 9 days at a 4.7 percent bounce rate, where deliverability and cadence caps come first.
Failure mode: A member-communication audit used David's Gmail inbox as its instrument and concluded OTP sends almost no proactive member email. Wrong by construction: the nudge engine hard-suppresses any dsteel@ address and the whole sneeze.it domain, so his inbox saw 5 of 318 real sends. The audit missed the live 90-day lifecycle series and the live re-engagement engine entirely.
When a user reports an error on an in-product button, diff the render condition against the API's permission check before assuming a bug in the handler. Then check how WIDE the gate is: grep every call site of the permission function. If a gate guards exactly one feature while sibling actions in the same UI are open, the gate is an outlier to remove, not a policy to widen. Confirm the failure against the live DB (here: read_at still null) so you know the write actually failed rather than the UI mis-rendering. When the fix is the ABSENCE of a check, add a source-assertion test and mutation-test it by re-injecting the gate — nothing else fails when someone re-adds it.</what_to_do> <parameter name="why">Permission bugs report as "it threw an error" and look like handler bugs, but the defect is the gap between who sees an action and who may perform it. That gap is invisible in either file alone. Counting the affected seats turns one person's complaint into a sized problem, and checking the gate's other call sites is what distinguishes "loosen this one route" from "widen a role everywhere" — the second has far more blast radius and was the wrong answer here.</why> </invoke>
Why: SUCCESS: Claude — a user-reported "error clicking a button" in OTP was a permission gate mismatch, not a crash: the meeting Signals "✓ Addressed" button rendered unconditionally in headlines.ejs while the /read API gated on canIntegrate(), so 25 of Sneeze It's 86 seats (every manager/managee/member) got a 403 on a button they could always see. Kristen could not even close a Signal she had authored.
Failure mode: SUCCESS: Claude — a user-reported "error clicking a button" in OTP was a permission gate mismatch, not a crash: the meeting Signals "✓ Addressed" button rendered unconditionally in headlines.ejs while the /read API gated on canIntegrate(), so 25 of Sneeze It's 86 seats (every manager/managee/member) got a 403 on a button they could always see. Kristen could not even close a Signal she had authored.
"Exhausted" means every avenue is closed, not that the first tool came back empty. Three specific rules from David 2026-08-06: (1) A local operator or GM is a legitimate route IN, not a disqualification. If they respond they can move you up the ladder to corporate, which is a better introduction than a cold email to corporate ever is. Do not disqualify a row because the contact you found is below the ICP's stated altitude. (2) When a brand's specific location has no contacts, look laterally across the SAME BRAND: other locations, other franchisees, the franchisor itself. A dead domain is not a dead brand. (3) When Clay has no email, go to the open web: the company's own site, LinkedIn, press releases, local news. Clay is the first tool, not the only one, and an empty Clay result is a prompt to search harder rather than a verdict.
Why: This is the same failure as L155 one level down. There I stopped the batch on a blocker; here I stopped individual rows on the first empty tool response. Both times "exhausted" was really "the cheap path did not work." The cost is asymmetric and worth internalising: an extra ten minutes of searching costs almost nothing, while a wrongly-abandoned row costs an account worth $50k to $500k a year in this ICP. David has now corrected the same underlying instinct three times in one day (L155, this, and the earlier stop-and-report), which means it is a disposition rather than an incident. When about to write "exhausted", "no path", or "needs research", treat that as a signal to try two more angles first, and record which angles were actually tried so the claim is auditable.
Failure mode: I marked four outreach rows "exhausted" that were not. Max Fitness Augusta: I disqualified it as "a one-location operator, not a franchisor, wrong ICP shape" and sent nothing. Class UFC Gym San Antonio: Clay returned no contacts at classufcgym.com so I stopped, without ever looking at other UFC Gym locations or franchisees in the same brand. LifeBridge: I said the one unblocked address was unverifiable and quit, without web-searching the person at all. Always Best Care: Clay returned no email for the CMO and I stopped there too.
Never report a campaign as active from campaign.status alone. Join status against metrics.impressions over the reporting window and call a campaign active only when status is ENABLED and impressions are greater than zero. Report the zero-delivery ENABLED shells separately as an account hygiene item.
Why: ENABLED is a switch, not evidence of delivery. Stale campaigns left switched on inflate the apparent size of an account, and a client who reads their own account daily will spot the mismatch immediately, which costs credibility on every other number in the same document.
Failure mode: Reported "four campaigns are enabled and spending" for the GLS Google Ads account by reading campaign.status alone. Riya and Yaroslav corrected it to three. Seven campaigns actually carried ENABLED status, but four legacy ones had zero impressions and zero spend for 90 days, so status count and active count disagreed in both directions.
Treat the recurring reports.sneeze.it 'Action Required / Data Source connection' emails as NOISE and suppress them entirely. Those connections go up and down by design. Never surface them in a briefing, never age them as an open flag, never ask David to decide about them.
Why: A recurring automated notice that self-resolves is not a signal. Aging it daily made a non-issue look like a 36-day-old dropped ball, which is exactly the noise the morning pass exists to remove. Age-as-signal only works if every flag on the board is genuinely unresolved; one permanent false positive degrades trust in the whole delta list.
Failure mode: Dan carried 'reports.sneeze.it Facebook Insights + LinkedIn connection failing' as an open flag for 36 consecutive days in the morning briefing, aging it up as if it were an unresolved decision, and framed it as needing a keep-or-kill call from David.
When a live source has been queried this run, EVERY item reported from that source must be derived from the live response, never carried forward from the prior snapshot. Use the previous snapshot only to compute age (first_seen) and to detect what closed. Concretely for good-morning: after pulling otp-todo.sh, rebuild overdue_otp from the returned todos, then diff against yesterday's list. Anything present yesterday and absent today is RESOLVED and gets reported under the resolved list, not repeated as open.
Why: The snapshot exists to add memory, not to override live truth. Carrying it forward inverts the design and turns the delta engine into a source of false positives, which is the single most expensive failure mode for a briefing: it costs David trust in every other line on the board and makes him do verification work the pass was supposed to remove. It also silently steals credit for completed work, which is worse than merely being noisy.
Failure mode: Dan pulled David's live OTP todo list at the start of the 8/7 morning run, then built the 'overdue' section from the PREVIOUS day's good-morning-state.json snapshot instead of from the live pull sitting in the same context. Result: three items were reported to David as open and overdue when they were not in his live open list at all, including SOC 2 G1 which David had already completed. David had to correct it.
In otp-platform, NEVER stage with `git add -A`, `git add .`, or `git commit -a`. Always stage by explicit path and verify with `git diff --cached --name-only` before committing. When multiple sessions may be working the same checkout, do not switch branches in the shared working tree at all: create an isolated `git worktree` instead, because another session's uncommitted work can be destroyed by a branch switch or reset. Before any reset in a shared tree, run `git status --porcelain --untracked-files=no` and use `--mixed`, never `--hard`.
Why: A repo with one engineer has no reviewer to ask "why does a coaching PR touch the risk register?", and no automated gate was asking either. The consequence is silent: the change is correct by every mechanical measure and wrong about what it claims to be. It is worse for compliance artifacts than for code, because the swept file becomes audit evidence with a misleading provenance. Parallel Claude sessions on one checkout make this far more likely, since each session sees the other's files as ordinary working-tree state.
Failure mode: Working-tree files belonging to one workstream were swept into an unrelated feature PR by a broad `git add`, and merged to main. PR #515 ("Let people actually talk to coaching, through Ollie") carried five SOC 2 compliance documents into main. Every CI check passed, because a swept markdown file breaks no test. This was the third occurrence in the otp-platform repo (#511 swept scratch, #512 removed it, then #515).
Before touching package-lock.json, compare the local Node/npm version against CI's (grep node-version .github/workflows/ci.yml). If they differ, do not regenerate the lockfile: install the matching runtime (brew install node@20, then use /opt/homebrew/opt/node@20/bin explicitly) and run BOTH the fix and the verification under it. Under the correct npm the same npm audit fix touched 12 lines instead of 524. Also: npm audit reads the INSTALLED tree when node_modules exists, and the lockfile only under --package-lock-only, so a local "0 vulnerabilities" measured against a node_modules you already mutated proves nothing. Reproduce CI with: npm audit --omit=dev --audit-level=high --package-lock-only
Why: A lockfile's correctness is defined by the tool version that reads it, so local verification with a different major version is not evidence at all. The failure is silent and expensive: every attempt looks verified locally and fails identically in CI, costing a full CI run each round trip. Checking two version numbers first would have cost thirty seconds and saved ninety minutes plus three abandoned pull requests.
Failure mode: Spent roughly 90 minutes and three failed PRs trying to clear a red npm audit CI gate in otp-platform. Each attempt regenerated package-lock.json locally and verified it with a local npm ci that passed, yet CI rejected every one as out of sync. Root cause was checked last instead of first: CI runs Node 20 (npm 10) while the laptop runs Node 25 (npm 11), and the two dedupe the dependency tree differently. npm 11 rewrote 524 lines and collapsed vitest's nested esbuild tree; npm 10 then refused it.
Never report an email thread as awaiting David from an inbox-only read. Before naming any thread as owed, unanswered or aging, read the full thread and search sent mail for the counterparty address. If no reply is visible in either, it may still have been handled through a channel we do not scan, such as a video walkthrough, a change made directly in the client system, or a phone call, so present it as no reply visible in email, confirm before acting, rather than as an open commitment. Apply the same rule to to-dos: an OTP item still showing open is evidence the checkbox was not ticked, NOT evidence the work was not done. And never read a flat metric as a failed result when the underlying send is a deliberate multi-batch rollout still in progress; check whether the work is phased before calling it dead.
Why: Third consecutive occurrence of the same class of error: 8/7 a client reply David had already sent, 8/7 the SOC 2 G1 review he had already run, 8/10 both Serotonin and GLS already handled. It spends David's scarcest resource, his attention, on work that is already finished, and it corrodes trust in the delta engine, which only earns its keep if still open, day N genuinely means still open. Our observability is limited to the channels we scan while David acts across many we do not, so absence of evidence in Gmail must never be reported as evidence of inaction.
Failure mode: The 2026-08-10 good-morning briefing named the Serotonin reply to KT Remus as David's single most important deliverable of the day, aged it to day 19, and staged a drafted reply for approval. David had already answered KT the day before with a video walkthrough plus the actual GHL changes made. The briefing searched only the Gmail inbox for the last 48 hours, saw KT's Saturday email sitting there, and inferred no reply had been sent. It never read the thread forward or checked sent mail. The same run also reported GLS Credit Amount as 13 days overdue when David had already emailed it, and read the HiTone $1 enrollment blast as having produced no visible result when David is deliberately sending it in batches and it is still mid-flight.
A prep brief and a facilitated meeting are two different artifacts and must never be delivered the same way. The prep file is read alone, in advance, and may be dense. The MEETING is turn-based: post section zero, stop, and wait for David to respond before posting section one. Never send more than one agenda section in a single message, and never send a section plus a question about a later section. When David invokes /l10dan at meeting time, the correct opening move is the preflight ONLY, followed by silence. Within the IDS section specifically, lay out every signal grouped and neutral and then STOP; Dan's recommendation comes only after David has reacted, never in the same message as the signals.
Why: Facilitation is the entire job of the strategic co-founder seat in this meeting. Dumping the full board removes David's ability to react, redirect, or spend time on the one section that actually matters to him, which is the only thing a live meeting offers over reading a document. It also silently converts a 15-minute conversation into a reading assignment. The deeper pattern is that Dan can recite a rule and break it in the same breath, which means the contract has to be enforced by the delivery mechanism (post, wait, post) rather than by intention. Third consecutive week the meeting has failed on structure rather than content: 7/13 scored 4 for not walking the surfaces, 7/20 scored 2 for context bleed, and now 8/10 for delivering a meeting as a document.
Failure mode: On 2026-08-10 Dan ran /l10dan and delivered all nine agenda sections of the Delta Meeting as one continuous message: preflight, Ollie, check-in, scorecard, corporate rocks, our rocks, headlines, to-dos, IDS with meta-model, and a closing question. David's response: "are we doing an L10 or are you spitting things at me all at once?" Dan had read the agenda contract aloud minutes earlier, including the rule that says one section per message and never two sections in one block, then violated it immediately. Dan also pre-picked the fork in the IDS section ("my take: invert the Dan tile or retire it") before laying signals out and letting David react, violating L059 in the same message.
Live OTP is the source of truth for STATE (is it open, what is the value) but not automatically for SCOPE (whose rock is this). When a live row contradicts a recorded disposition, surface the conflict as the finding instead of silently siding with the row: say "the board still shows X under our team, but it was dispositioned to Y on DATE, which is it." Before presenting any rock, to-do or issue as ours, check whether it has a prior disposition moving it to another team or owner, and check for a near-duplicate title on another board. Two rocks whose titles differ only by a suffix (Delivery Team Structure - Right People Right Seats versus Right People Right Seats - Every Seat Owns a Number) are a duplicate-and-ghost signal, not two real pieces of work.
Why: This ghost rock has now been closed twice, on 7/13 and again on 7/27, and resurfaced both times. Each time it costs meeting minutes and pulls the agent-army board's attention onto work that belongs to the COO. Worse, Dan used it as evidence for a pattern claim about writes not persisting, which would have sent the room chasing a phantom infrastructure problem. Reporting another team's work as ours is the same context-bleed failure the agenda contract was written to prevent, just in the opposite direction: the contract guards against OTP work landing on the Sneeze It board, and this is Leadership work landing on the AI Army board. The guard has to run on scope in both directions, not just on company.
Failure mode: At the 2026-08-10 Delta Meeting, Dan presented "Delivery Team Structure - Right People, Right Seats" as one of the two AI Army quarterly priorities and built a whole observation on top of it about writes not persisting. David corrected it: that rock is Bogdan's, not the agent army's. Dan had read issues-agent-army.md earlier the same morning, which explicitly records the 7/13 disposition "Ghost rock Delivery Team Structure CLOSED, rolled into corporate Q3 rock Right People Right Seats Every Seat Owns a Number (Bogdan, 9/30)", and still reported the ghost as ours because the live board returned it under the AI Army teamId. Dan treated a board row as authoritative on OWNERSHIP when the board row was the very artifact known to be stale.
When a spec names a hard constraint (ungated, no auth, read-only, no writes), treat it as the acceptance test and verify the chosen implementation against it BEFORE building. Before pointing any page or doc at an endpoint, read that endpoint's auth path in code. Here a 30-second read of src/routes/api/mcp.ts would have shown every remote route calls resolveTokenString first, so no ungated endpoint existed and one had to be built (POST /api/mcp/public).
Why: Violating a stated constraint invalidates the whole artifact rather than a detail of it, and this would have shipped a login wall on the exact surface designed to remove one. Generalizes: whenever the plan is "use what we already have", confirm the existing thing meets the stated constraint instead of assuming the nearest component is close enough.
Failure mode: David's spec said the learning connector is UNGATED (no auth, no login, separate from the gated org-data connector). I recommended "point the page at what exists today" and built it against https://orgtp.com/api/mcp, the authenticated endpoint requiring sign-in plus a paid Claude plan. A full page of copy, design, and a screenshot were built on the wrong premise before David caught it in the preview.
Treat the "[email] UNTAGGED SEND -- no kind, so no preference can gate it" warning as a blocking finding, never log noise: an untagged send falls into the hidden 'other' bucket, so a person who switched off that exact category still receives it (an active pause is the only thing that stops them). Before sending any broadcast, verify the gate against production by calling filterByPreference with both the untagged and the intended kind and comparing who is blocked and FOR WHAT REASON. Same-answer-different-reason is a latent bug, not a pass. Also: a preference-blocked recipient makes sendEmail return false, which the broadcast script counts as a failure. It is not one, and it must never be retried with --only=.
Why: We shipped a preference page announcing per-program switches, then sent the weekly in a way those switches could not govern. One customer had the newsletter switched off and was spared only by an unrelated 2036 pause. The say-vs-do gap is the exact thing OTP's constitution exists to catch, and it would have been invisible without reading a warning line that scrolled past in a successful run.
Failure mode: Both weekly senders called sendEmail with no `kind`, so In The Swamp was not governed by the "In the Swamp" switch on the preference page. Caught at the issue #32 gate, one issue after we announced that switch as a feature.
Two Stripe facts to hold. (1) ATTACHED IS NOT DEFAULT: `setup_future_usage: 'off_session'` attaches a payment method to the customer but does NOT set `invoice_settings.default_payment_method`, and Stripe auto-selects only from the default. Off-session charges must name `payment_method` explicitly. (2) NEVER list payment methods with `type: 'card'` when checking whether a customer can be charged. Checkout saves Link payment methods as type `link`; a card-only list returns zero for customers who have paid repeatedly. Query with no type filter. More generally: when a fix ships with "not verified end-to-end against the live provider", treat the unverified segment as still broken and schedule the verification, because the next bug report will come from a customer.
Why: The feature reported itself as ON to two paying orgs and had never once succeeded in production: zero auto_recharge PaymentIntents ever created, zero wallet credits ever attributed. The only signal was a log.warn nobody reads. The card-only filter would have turned a real diagnosis into a wrong one aimed at the best customers.
Failure mode: Auto-recharge charged nobody for weeks after the trigger was fixed: an off-session PaymentIntent naming only a Stripe `customer` cannot find their saved payment method, and while diagnosing it I nearly concluded the customer had no payment method because I listed with `type: 'card'`.
Do not flag CCM call-recording Make scenario errors as an open issue. David confirmed 2026-08-13 that the CCM recording connection goes up and down by nature and the warning emails are expected noise, not a fault. Drop the flag, stop aging it, and do not re-raise on new recording-scenario error notices unless call recordings are confirmed actually missing from the CCM sheet or a client reports it.
Why: A recurring self-healing warning that gets aged day over day looks more serious each morning purely because the counter goes up. Thirty-seven days of escalation on expected noise crowds out real flags and trains David to skim the Watch line, which is exactly where genuinely dark sources get named.
Failure mode: Radar carried "CCM / Make recording automations down" as an open flag in the daily morning briefing for 37 consecutive days, escalating it as a five-scenario pattern and routing it to Zeynep, treating each Make scenario error notice as evidence of a persistent client-facing failure.
Any hand-rolled Node HTTP server we ship must (1) wrap decodeURIComponent in try/catch and return 400 on failure, (2) reject paths containing a null byte before touching fs, (3) wrap the whole request handler in try/catch, (4) register process.on('uncaughtException') and ('unhandledRejection') so no single request can kill the process, and (5) expose a /health endpoint for the uptime monitor to watch instead of the homepage. When one such site is found broken, immediately grep every other project for the same server pattern rather than fixing only the site that was reported.
Why: In Node an uncaught throw in a request handler is fatal to the whole server, so one hostile request from a routine bot scan is a full site outage. The failure is invisible in normal browsing because the homepage always returns 200, which is why it was misdiagnosed as hosting flakiness. It also generalises: the same generated server template was copied across client sites, so finding it once means finding it everywhere, and the site nobody is monitoring is the dangerous one.
Failure mode: Static client sites built with Claude and deployed to Railway (Optimum Wellness Center, Results Redefined) shipped a hand-rolled Node server whose request handler calls decodeURIComponent(req.url) with no guard. A malformed percent-encoded URL such as "/%" throws URIError inside the handler, and because nothing catches it the entire Node process exits. Railway restarts the container, the uptime monitor sees the gap and emails, and the cycle repeats indefinitely. Optimum Wellness ran this way for weeks and the flood was read as flaky hosting rather than an application crash.
Rocks, Issues, KPIs and to-dos live in OTP, not in local markdown files. Before reporting Rock status in any Leadership Meeting prep, read the Rocks from the OTP meeting page or API, and treat ~/.claude/rocks.md and ~/.claude/issues.md as archive material only. If a local file's last-updated date is older than the current quarter, do not report from it at all: go to OTP. Never state that something does not exist on the evidence of a stale local file. Say "not found in <source>, checking <system of record>" and then check.
Why: Sneeze It runs its meetings on OTP. Reporting "no Q3 Rocks" to the leadership channel was not a small inaccuracy: it told the COO and Creative Director that the company had no quarterly targets, which is both false and corrosive to trust in the prep. A stale file proves that the file is stale, never that the underlying thing is missing. This is the same failure class as reading a dead data source as a real zero (OOS L128).
Failure mode: Radar reported "there are no Q3 Rocks" in the Leadership Meeting prep and posted it to #leadership-l10, because the local file ~/.claude/rocks.md was last updated 2026-04-14 and still listed Q2 Rocks. Radar treated a stale local file as the source of truth for Rocks. In fact three Q3 Rocks exist and are live on the OTP meeting page (orgtp.com/l8/meeting/31b6cee9-e21a-42ef-9a98-4614dea1f31f#rocks). The error was then amplified: it was named the single most important agenda item and the whole meta-model analysis was built on a false premise.
Never state that a thing does not exist on the basis of a partial read. Before any absence claim: (a) if the command was piped through head/tail/limit, re-run it unpiped or with a count first, (b) if the source is a local file, check its last-updated date against the current period and go to the system of record instead, (c) if it is an API, remember that a null field is not proof of no data, since the same record may track state in a related table. Phrase findings as "not found in <exact source I read>", never "does not exist". An absence claim requires an exhaustive read; a presence claim only requires one hit.
Why: Absence claims are asymmetrically dangerous. Saying something exists when it does not gets caught the moment someone looks; saying something does not exist ends the search, so nobody looks again. Here it produced a false leadership-meeting headline, an unfair judgment about two people's work, and a materially wrong assessment of how exposed a private note had been. `head` truncating a grep is the single most common mechanical cause and is invisible in the output.
Failure mode: Three times in one session Radar reported that something did not exist, based on an incomplete read rather than a complete one. (1) "No Q3 Rocks" from a stale local rocks.md when 9 existed in OTP. (2) "The company Rocks have never had a status note or next action" from two null API fields, when the rocks were richly tracked via milestones. (3) "statusNote does not render on the meeting page at all" from a `grep -rn statusNote src/views/ | head -8` whose output was truncated by head, when it does render in src/views/partials/meeting/rocks.ejs. Each claim was stated flatly as fact, and #3 caused a wrong reassurance about how exposed a sensitive note about a named person had been.
Sneeze It OWNS iCart CM 2021 outright. It is not third-party licensed software. Its code can be ported, rewritten, or relicensed into new Sneeze It products (e.g. iJoin) freely. More generally: never assert an ownership, licensing, or IP constraint on a Sneeze It asset based on a README, code comment, or doc written by a teammate. Ownership facts come from David. Verify with him before letting an IP claim narrow the scope of work.
Why: The false constraint would have thrown away years of production-hardened ABC Financial integration logic (endpoint handling, field mappings, retry/backoff, dedup guards, plan ingest quirks) and forced a from-scratch rebuild of a working system. IP claims that shrink scope are expensive when wrong, and a teammate's README is not a legal record.
Failure mode: Claimed iCart CM 2021 is third-party commercial software that Sneeze It cannot port, and told David the new iJoin repo could only carry over 'knowledge' rather than code. The claim came from a line in a repo README written by another team member, which I treated as authoritative without checking with David.
iJoin is NOT an iCart replacement. It is self-service infrastructure where Sneeze It is the metered middleman: client signs up with a credit card, funds a wallet (same model as OTP), gets an MCP their own AI/dev team uses to BUILD their own cart/calendar/shopping platform, and gets their own iJoin API that their built cart calls at runtime — with iJoin acting as the conduit through to ABC. Revenue is metered API charges drawn from the wallet, including third-party integrations. A co-equal product is reporting: traffic source, plans chosen, conversion rates. Test mode and live mode, Stripe-style. Plan data IS stored (that is what keeps ABC ping costs down); customer data is NEVER stored. iCart's habit of dumping lead data into Google Sheets is an explicit liability not to carry forward.
Why: Reading an existing codebase first anchors the new product to the old one's shape. iCart is a per-client WordPress plugin that Sneeze It installs and operates; iJoin is self-service infrastructure the client builds on top of. Getting this backwards produces the wrong architecture (renders the cart instead of being the pipe), the wrong revenue model (per-seat/retainer instead of metered wallet), and misses that reporting is a product rather than a feature. When a new product shares a data source with an old one, ask what the BUSINESS MODEL is before inferring the architecture from the prior art.
Failure mode: Framed iJoin as a re-delivery of iCart — the same ABC join flow exposed over MCP, with the existing iCart checkout still doing the work. Built a repo around that assumption after reading the iCart codebase, treating the old product as the template for the new one.
Verify secrets by VALUE LENGTH, not by presence of the name: `railway variables --kv | awk -F= '{print $1, length($2)}'`. Any security-relevant variable at length 0 is unset. Do this check after every variable add and as part of any "is it configured?" question.
Why: Railway will happily hold a variable with an empty value, and every listing that shows names makes it look set. The failure surfaces far from the cause — a 500 in a checkout flow, or worse, no error at all when the empty value merely weakens something like a hash pepper.
Failure mode: iJoin checkout links 500'd on the live site with "CHECKOUT_SIGNING_SECRET is not set" even though the variable had been added to Railway and deployed. Listing variable NAMES showed it present, so it read as configured. It had been created with an empty value — as had API_KEY_PEPPER, which silently means unsalted API-key hashing.
Use American spelling in all Sneeze It / iJoin / OTP product copy, UI labels, and code identifiers. Where a vendor has an official term, match theirs exactly — ABC Financial's own form says "Client Data Release Authorization", so it is "authorized", never "authorised".
Why: These are US products sold to US gyms, and the copy sits next to a vendor's own paperwork. A British spelling on a compliance screen reads as an error in the product, not as a dialect choice, and it undermines the one screen whose whole job is to look trustworthy.
Failure mode: Wrote British spellings ("authorised", "authorisation", "summarise") throughout iJoin's user-facing copy and code. David caught it on the live-mode gate screen: "Authorized is spelled wrong". 19 files affected.
Boot-time ensure-* files are schema-only. Any data backfill must be bounded so it can only ever touch a row once (a marker column, a one-time script under scripts/, or a guard that the row was never touched) -- never a bare "WHERE col IS NULL" that later code treats as a legitimate state. Add a guard test asserting the ensure file has no UPDATE/INSERT/DELETE against the table. When a privacy rule makes NULL a meaningful state, grep every ensure-* for writes to that column first.
Why: Idempotent-looking migrations that run every boot are a hidden cron job. When a later product decision changes what NULL means, the old backfill keeps re-applying and turns a privacy rule into a per-deploy data leak that no test catches, because tests never run two deploys.
Failure mode: OTP boot-time ensure migration (ensure-meeting-teams.ts) carried an unbounded UPDATE (meetings.team_id = leadership WHERE team_id IS NULL). Because ensure-* runs on every deploy, every redeploy swept every unassigned meeting -- members' private calendar auto-join recordings -- onto the Leadership Team, so Kristen (and every leadership member) could open David's client/coaching/1:1 recordings. It also silently undid a later privacy rule (#499) after every deploy.
Read the numeric UTC offset in the dateTime string as the single source of truth for event time. The sibling timeZone field is a display label for how the organizer authored the event and must never be used to re-convert an already-offset timestamp. Convert only from the offset: -04:00 during EDT is already Eastern, so no math is needed. Never flag a calendar collision without first confirming both events resolve to the same absolute UTC instant.
Why: A fabricated conflict makes David chase a scheduling problem that does not exist and, worse, erodes trust in every other flag in the briefing. The good-morning spec already carries an explicit warning to verify timezone before calling a conflict because this class of error has burned us before with GLS calls. Double-converting an offset timestamp is the specific mechanism behind it.
Failure mode: Reported a false 3pm calendar collision. The Google Calendar event returned start dateTime "2026-08-21T13:00:00-04:00" alongside a timeZone label of "America/Denver". I read the Denver label and shifted 13:00 forward two hours to 3pm ET, then flagged it as colliding with the 3pm Tom McFadyen weekly. The -04:00 offset was already Eastern, so the call was 1pm ET and there was no conflict.
Any CSV importer must show a column-mapping preview and dry-run summary BEFORE committing rows: which source column maps to which field, how many rows land in each validation status, how many will have a null first_name. Recognize provider-native aliases (status, email_status, verification_status, name, full_name) instead of one exact-match column name. When a field silently defaults for more than half the rows, that is a loud warning on the import result, not a silent default. And when the user says their data is clean, check whether the ingest path dropped it before concluding the data is bad.
Why: A silent default on validation_status makes a fully-validated 3,000-contact list look unusable and blocks every blast. A silent null on first_name ships broken personalization to real prospects, burning both the list and the sending domain reputation. Both failures are invisible at import time and only surface days later as the tool is broken. The import result screen is the last cheap place to catch a bad mapping.
Failure mode: Outreach Engine CSV importer silently discarded Clay's validation verdict and contact names. David validated 3,077 gym prospects in Clay before importing, but the importer only recognizes a column literally named validation_status, so Clay's status column (value: valid) fell into the generic attribute bag and all 3,077 contacts defaulted to validation_status=unknown, which excludes them from every audience. Same failure on names: the CSV had one name column, the importer only maps first_name/last_name, so first_name is NULL on all 3,077 and first_name personalization would have silently broken in every sent email. I also initially diagnosed this as the list being unvalidated rather than the importer dropping the verdict.
iJoin outreach sells the PRODUCT and its uniqueness, never a build service. The selling points, in order: (1) it connects to the AI the prospect already uses (Claude, ChatGPT, any MCP client), (2) it gives THEM the keys to create their own join process, unique to them, not a vendor template, (3) unlimited variations so they can test as many join flows as they want, (4) it runs live against their real member system (ABC, Glofox, Zenoti). The two hour North Pole Fitness Club build is PROOF of what the tool enables, cited last, never the offer itself.
Why: Sneeze It already sells services. iJoin is metered self-service infrastructure where the client's own AI or dev team builds and iJoin brokers the calls, so revenue is API metering, not project fees. Framing it as 'we build it for you' sells the wrong business model, caps revenue at agency hours, and hides the one thing no competitor has: an MCP surface a gym's own AI can build against.
Failure mode: Drafting the first iJoin cold email, I wrote it as an agency services pitch: Sneeze It builds you a join site, iJoin is 'the layer' between you and ABC, proof point was our two hour build. That sells a done-for-you project. It buries the actual product.
Never assert that a live config value is wrong from a memory note alone. If the value cannot be verified right now, say what the note said, say it is unverified and how old it is, and ask the user to confirm. A stale note is evidence about the past, not a claim about the present. Applies double when the same sentence admits the system is unreachable: no access means no assertion, only a question.
Why: False maintenance items cost the user real attention and erode trust in every other flag in the same report. David had to open the settings page, screenshot it, and ask whether he was missing something, to disprove a claim I had no basis for. The existing rule trust_observed_behavior_over_stale_notes covers stale notes about past work; this extends it to stale notes about current configuration.
Failure mode: I told David the outreach engine's iJoin brand still had the wrong Calendly link and needed fixing. It was already set to calendly.com/davidsteel/ijoin-intro. I sourced the claim from an 8/21 memory note saying the non-OTP brands carried David's agency Calendly, and repeated it as current fact without verifying, in the same breath as admitting I could not reach the dashboard to check.
When litigation or a conflict involves a franchise brand, scope the exclusion to the actual legal entity before flagging anything: the named plaintiff, its locations, and its email domain. Never let a brand name in a case caption become a brand-wide never-contact rule. For Gold's Gym specifically: only the Laredo Group / goldsgymtx.com is excluded, and all other Gold's locations are in ICP and contactable (David, 2026-08-22).
Why: Gold's is 166 contacts, the fourth largest brand in the audience, so a brand-wide flag silently deleted a meaningful chunk of the pipeline over a conflict that did not exist. Repeating an unverified flag also burns the user's attention and trains them to ignore real ones. Franchise ICPs are the norm in fitness and med spa, so this error class recurs: the case caption names the brand, the defendant is one operator.
Failure mode: I flagged all 166 Gold's Gym contacts in the outreach audience as legally risky and raised it three times as a blocker, on the grounds that Sneeze It has active Gold's Gym litigation. The suit is Gold's Gym Laredo Group, seven locations in South Texas, and my own memory said so. Gold's Gym is a franchise brand of independent operators, so the plaintiff is one franchisee group, not the organization. The rest of the brand was never a conflict.
Evaluate a blast's health on EVERY tick once the minimum sample is reached, not only when a tranche is exhausted. A tranche is a batching unit, not a safety unit: when a daily ramp cap is smaller than a tranche, "check at tranche end" silently means "check in a week". Separately: never describe a safety mechanism's timing from the design intent. Read the call site and confirm what actually triggers it before telling anyone they are protected.
Why: A circuit breaker that cannot fire during the period it is meant to protect is worse than none, because it buys false confidence. Here it let a brand new root sending domain take 8% bounces unchecked, which is the exact reputation damage the tranche design exists to prevent, and the operator had gone offline believing the system would stop itself.
Failure mode: I told David the blast circuit breaker "starts biting after just 20 attempted sends, so protection is live from the first hour". It does not. evaluateBlastTranche() is only reached in the worker's else branch, which runs when a tranche is FULLY attempted. Tranche 1 was 256 contacts at 50/day, so the first health check was five days away. The first 50 sends hit 8% hard bounce on a root domain whose threshold is 3% and nothing stopped. I had to pause it by hand.
Pace against elapsed time, not against a per-tick quotient: earned = dailyCap * (minutes elapsed in window / total window minutes), allowance = earned - alreadySentToday, clamped to a per-tick ceiling. This is stateless, self-correcting after an outage, and cannot degenerate when the cap is smaller than the tick count. A max(1, ...) floor inside a rate limiter is a red flag: it converts "spread this out" into "send at maximum tick rate".
Why: The drip exists so a young domain does not look like a burst sender. Consuming a whole day's allowance in the first hour of the window produces exactly the pattern it was written to avoid, and it did so on the very first real campaign, while the operator had been told a different number.
Failure mode: I told David the drip would pace 50 sends/day at "about five an hour". It sent all 50 in roughly the first 50 minutes. dripAllowance() computes ceil(remaining / ticksLeft) then wraps it in max(1, ...), so whenever the daily cap is smaller than the number of ticks left (50 sends vs 600 one-minute ticks), the even share rounds to 0, the floor forces it to 1, and the worker sends one every single tick until the cap is gone.
When matching an existing product's design, read the tokens off the RUNNING site, not its design document. Open it, pull the computed CSS custom properties and the real font stacks, and build from those. DESIGN.md in otp-platform is stale relative to production: OTP actually ships --otp-canvas / --otp-surface / --otp-ink-900 with --otp-blue as primary, --otp-green (lime) as accent, and a semantic ontrack/watch/offtrack trio, in Inter and JetBrains Mono. A design doc describes an intention; the site is the fact.
Why: A spec and a shipped product drift, and the product is what the user sees every day, so it is the thing "make it match" refers to. Building from the doc cost a full redesign cycle and produced something the owner did not recognise as his own brand. Same failure class as trusting stale notes over observed behaviour.
Failure mode: Asked to style Outreach like OTP, I built from otp-platform/DESIGN.md, which specifies a warm amber "departure board" palette. The live orgtp.com is a cool near-white canvas with royal blue for action and lime for the primary CTA. The two share no colour, so what I shipped looked nothing like OTP and David said so. I never opened the site.
Separate a TRADEMARK matter from a RELATIONSHIP matter. The 2026-06-29 EOS Worldwide notice was about not using their marks (EOS, L10, Level 10, IDS, V/TO, GWC) in Sneeze It and OTP copy. That compliance work had a ~2026-07-29 deadline and is done. It never restricted emailing eosworldwide.com addresses, and the older 2026-05-12 no-blast note did not survive it. Before blocking a domain on a legal or relationship premise, state the premise to David as a question rather than acting on a memory note, because a stale note reads exactly like a live constraint.
Why: Blocking is reversible, so the cost here was small, but the same reasoning applied in the other direction (declining to contact a partner, or holding back a campaign) silently costs revenue with no error to notice. Memory notes carry the state of the world when written; a legal matter in particular changes underneath them and the note does not update itself. The 18 eosworldwide.com contacts are independent EOS implementers and are in-ICP for OTP nurture per L095.
Failure mode: Blocked eosworldwide.com from the OTP outreach engine on 2026-08-24, asserting there was an "active trademark dispute" and a standing no-blast rule. David corrected: eosworldwide is fine to send, there is no dispute. The block was reversed the same day.
When a page borrows a demo or client brand as its visual skin, the skin sets the look and never the message. Every headline, CTA and nav label must still name the real product and the real thing being booked, and must say plainly what the borrowed brand is. Test by reading only the h1, the lead and the buttons: if that alone does not say what is being sold, the skin has taken over.
Why: A cold-email visitor decides in seconds and has no context for an in-joke. Dressing the booking page as a fictional gym hid the product at the moment of highest intent, and would have sent prospects to a demo call thinking they were touring a gym.
Failure mode: Built ijoin.ai/intro wearing the North Pole Fitness Club skin and let the skin write the copy: h1 "Book the tour", panel "Walk the club this page is wearing", nav "Classes / Memberships". A gym owner from cold email reads that as booking a tour of a gym. The page is really a demo of iJoin, the platform; the penguin club is a fictional demo built on iJoin, used as a joke and as proof.
For GoHighLevel app installs, the in-platform host is app.gohighlevel.com/integration/<appId>/versions/<appId>. marketplace.gohighlevel.com is the public listing/developer portal and does not perform the install. This matters more than a normal URL slip because a paid app (including a $0/month plan) refuses external installs entirely with "Paid apps can only be installed within the platform" — so the in-platform host is the ONLY path that works. Do not derive an install URL by pattern-matching; ask for the working link or navigate the UI.
Why: Constructing a plausible URL from a pattern and presenting it without hedging sends the user to a dead page during an already long debugging session. When a link cannot be verified, navigation instructions are more useful than a guessed URL.
Failure mode: Gave David a GoHighLevel app install link on the wrong host: constructed https://marketplace.gohighlevel.com/integration/<appId>/versions/<appId> by pattern-matching an earlier URL. The working link is on app.gohighlevel.com, not marketplace.gohighlevel.com. I had also flagged the /versions/ segment as uncertain but got the host wrong instead.
At preflight, diff the window's entry dates against the last row of ~/.claude/swamp-log.md before anything else. If every entry in the window was already sent, STOP and treat it as a no-new-content week. Never widen the window to find entries, because widening is exactly what surfaces already-sent material. Date new entries to the issue's Monday and pick a --days value that clears the previous batch by days, not hours: this week used entries dated 8/24 with --days=2 for a five-day margin, because --days=7 excluded the prior 8/18 batch by only two hours.
Why: Five consecutive issues undercounted the week, which trained the operator to look for missing content and widen windows to find it. A genuinely quiet week presents identically at first glance but the correct response is inverted: widening re-sends the last issue rather than recovering unlogged work. A duplicate to the entire customer list is far more damaging than skipping a week.
Failure mode: The Swamp sender was one command away from re-sending issue #33 verbatim to 93 customers. The changelog had zero new entries for the week, so the default 7-day window threw on an empty window and the 8-day window silently returned issue #33's exact 12 entries dated 8/18. Nothing in the tooling flags that a window is made entirely of already-sent content.
Do not compute or report an unsubscribe, reply, or bounce RATE off a sample this small, and never recommend pausing a ramp on one. On cold outreach, unsubscribes are the expected and healthy outcome (far better than spam complaints), so the sub-1% benchmark from opt-in newsletter sending does not transfer. Let the denominator grow to meaningful volume, then measure and reset the baseline. Report small-sample counts as raw counts with the sample size attached, never as a percentage, and never extrapolate a small-sample rate across a full list.
Why: Pausing at n=15 destroys the only thing that would produce a trustworthy number, which is volume, and it stalls a domain ramp that needs consistent sending to build reputation. Framing noise as an alarm also spends the operator's attention on a non-problem and can kill a working campaign on a statistical artifact.
Failure mode: I read a 13.3% unsubscribe rate off a 15-send cold-email sample (2 unsubscribes), called it a five-alarm number against the sub-1% benchmark, extrapolated it to ~167 unsubscribes across the 1,259-name list, and recommended pausing the tranche before adding follow-up touches. David: two unsubscribes on a cold email is not a big deal, sending 15 and stopping is the mistake, keep going and reset the number.
Relay is a CONDUIT, not a system of record. GHL is where client and lead data lives; Jobber is the operational system for scheduled work. Relay's job is to pass information between them in real time, holding only what a pipe needs: credentials, location bindings, an identity map (Jobber client id to GHL contact id), an event ledger for idempotency, and a write ledger for audit. It must not accumulate a second copy of the customer base. Any Relay UI is a verification window for confirming data landed correctly, never the place the business goes to look things up.
Why: A connector that stores its own copy of customer data becomes a third system to reconcile, and reconciliation between three systems is strictly worse than between two. It also changes what "done" means: success is data appearing correctly in GHL, not a complete-looking dashboard inside the connector. Building the dashboard first optimises for a demo rather than for the job.
Failure mode: Built the Jobber/CRM relay as if Relay itself were a destination: a warehouse schema (parties, work_items, attributions) plus customer list and detail pages, framed around "connect everything so it is visible in Relay". Treated Jobber and GHL as two peer systems whose data Relay would hold and display.
Never let a failed API call resolve to a zero metric. In any spend or billing pull, an error must raise or be tagged as an error row, never silently become 0. Zero and unknown are different facts and only one of them belongs on an invoice. Two supporting rules: (1) when a shared CLI wrapper has its API version bumped, grep the whole toolchain for other files pinning the old version, because the wrapper is rarely the only caller; (2) at the top of every billing run, sanity-check each platform total against the known monthly baseline (Google ~$49.5K/30d, Meta ~$136K/30d) and stop if a platform reports zero or near-zero, since a whole platform reading $0.00 is a broken pull, not a quiet month.
Why: Billing accuracy is the one place a silent failure converts directly into lost revenue, and this failure mode is invisible by construction: the report is well-formed, the totals add up, and nothing is flagged. It survived two weeks precisely because nothing looked wrong. The same silent-zero pattern existed on the Meta side of the same file and would have done the same damage to a $132K platform the moment Meta returned an error.
Failure mode: The Ad Account Billing report silently under-billed every Google client for two weeks. google-ads.sh was bumped to Google Ads API v23 on 2026-08-12 when Google blocked v21, but billing_pull_spend.py was never bumped and kept calling v21. Every Google request returned HTTP 404, and the pull's error handling scored an errored account as spend = 0.00. The report looked completely healthy: it printed a clean table, a total, and no warnings, while reporting Google spend of $0.00 against a real $98,562. Billing came out $13,690 instead of $15,420, roughly $1,730/month short, and the same broken pull also hid $82,630 of unbilled spend that the standing sweep is supposed to surface every run.
The 10% default applies ONLY to a genuinely new client with no existing arrangement. Never apply it to an account that is already an active client, because an existing client already has a commercial arrangement and it may not be percentage-based at all. When David confirms an account is billable but does not name a rate, treat the rate as UNKNOWN and ask, rather than defaulting. Remember that several real clients bill outside the percentage model entirely (HiTone on retainer, South Coast and Cellebration per-lead), so "billable" and "billable as a percentage of ad spend" are different claims. An account that bills outside the percentage model belongs in the confirmed-exclusions list on the Review tab, not as a percentage line on the Billing tab.
Why: A wrong rate on the Billing tab flows straight to Janine and out to a client as an invoice line that was never agreed. Inventing a rate is worse than reporting an unknown one, because an unknown gets checked and a plausible-looking 10% line does not. Flagging it "CONFIRM" in a note is not sufficient protection once the number is already sitting in the billing column.
Failure mode: In the 2026-08-26 billing run, David said "Vent is billable" without naming a rate. I applied the 10% new-client default and put VENT on the Billing tab at $700/mo. David corrected: VENT is not charged a percentage fee at all. VENT is an existing active client, not a new one, so the 2026-07-02 new-client default was never the applicable rule. I treated "billable" as if it meant "billable at the default percentage".
An account living in our ad structure proves only that we have access to it, never that we manage or bill it. Our Meta business and Google MCC both hold accounts we merely have access to: franchise accounts billed by the franchisor, clients on retainer or per-lead, and accounts belonging to other parties entirely. So when the unbilled sweep surfaces an account, the standing rule "nothing spends without being billed" means SURFACE IT AND ASK, never "add it at 10%". Only add a percentage row when David names the client and the rate in the same breath. Everything else goes on the Review tab as unknown, where an unanswered question stays visible instead of quietly becoming an invoice line.
Why: A wrongly added row bills a client we do not manage, which is worse than missing revenue: it is an invoice we cannot justify, and it reaches the client through Janine without anyone re-checking the assumption. It also decays silently, because once the row exists it looks confirmed on every later run and its origin note ("10% default per the 2026-07-02 rule") reads like an approval it never was.
Failure mode: exhale was billing $1,150/mo at 10% on the Ad Account Billing report. David: "exhale is not billed we do not manage" and corrected the sheet himself. The row had been added on 2026-07-24 under the 2026-07-02 new-client 10% default because it appeared in the unbilled-spend sweep as an account spending money with no client row. Presence of a live ad account inside our Meta or Google structure was treated as evidence of a managed, billable relationship. It is not. The same wrong assumption produced three other bad rows in the 2026-08-26 run alone: the 13 Jerry Pugh WOA franchises, Phillip Jeffries, and VENT.
Before reporting that a data bug caused a financial consequence, establish whether the affected data ever reached an invoice. For this report specifically: it is MTD prorated to month-end and is invoiced at month end, so a bug found and fixed inside the current month has NO invoice impact. Only a bug spanning a completed, already-invoiced month does. State the blast radius as a checked fact ("July was invoiced before the break, August has not been invoiced, so no impact") rather than as a worst-case assumption, and never carry a speculative money impact forward as a standing action item.
Why: A false financial alarm costs the founder's attention and credibility at exactly the moment a real finding needs to land. The underlying bug was genuine and worth the escalation; attaching an unverified consequence to it made the whole report harder to trust and generated a look-back task that never existed. This is the verify-before-you-alert rule (OOS L018) applied to money rather than to stale files.
Failure mode: After finding the Google Ads v21 bug that made billing read $0 for Google from 2026-08-12, I repeatedly told David that "invoices raised between Aug 12 and today under-billed their Google spend" and pushed it as an open action item needing a look-back. David: "I dont think this is an issue... the sweep today should have gotten all the billing." He was right. The billing report is month-to-date prorated to month-end and Janine invoices at month end, so the entire broken window fell inside a month that had not been invoiced. The bug was caught before it ever produced an invoice, and the corrected 2026-08-26 run is the one August will actually bill from. I raised a financial alarm without checking whether the thing I was alarmed about had happened.
Before building ANY OTP-branded surface, read otp-platform/src/DESIGN.md first, and build the page inside otp-platform so it inherits the real tokens rather than copying a look. The law: Section 0 Jobs standard (90-second demo test, defaults are decisions, count the steps then remove one, invisible mechanism) plus the Fugu crisp scale (6px surfaces / 4px controls / 3px badges, no pills, no resting shadows, never a dark default, Inter, hairlines and whitespace instead of boxes, tokens only and never a raw hex, no responsive heading bumps). For any booking or payment moment, apply the iJoin /intro precedent: own the date, time and form flow in our own chrome and write the booking through the API. Never iframe a vendor scheduler at the moment of commitment.
Why: A page that claims an organization can run itself on one clean chart cannot itself look like a generic SaaS template, and a page that sells a coordinated system cannot hand the buyer to somebody else's widget to finish. The medium is the demo. OTP competes on presentation against success.co, so an off-system prospect page costs conversions twice: it looks like slop, and it proves the opposite of the pitch.
Failure mode: Built a prospect-facing OTP page (the sticky note one-pager) in a hand-rolled design invented from scratch: 8-14px rounded corners, pill chips, resting shadows, a dark booking block, a made-up amber/lime palette, system fonts, clamp() responsive heading sizes. Every one of those is explicitly forbidden by OTP's own design law. Then embedded the raw CloudCRM booking widget in an iframe, handing the visitor to a generic vendor scheduler at the exact moment of commitment. David: "this is not in the OTP FUGU/Jobs look and feel, not even sure where you get the design from... the calendar looks like shit as well, did you learn anything from iJoin?"
Before drafting a reply into an ongoing correspondence: read every message in the real thread both directions; open the attachments the other side already produced, OCR-ing scanned PDFs when the text layer is empty; diff planned asks against what has already been asked and answered and delete duplicates; affirmatively withdraw by name any question the new documents have answered. The memory file is a summary of the exchange, not the record of it.
Why: In a negotiation the cheapest thing to lose is credibility. Asking someone to resend what they sent an hour ago reads as not having read their answer, which destroys the impression a cooperative strategy depends on. Withdrawing a question the evidence has closed buys more standing than a new argument does.
Failure mode: Drafted a reply in a live email negotiation from the cached memory file alone, without reading the actual sent-and-received thread or opening the attachments the other side had already produced. The draft used a figure the counterparty had corrected hours earlier, asked for three itemized invoices already sitting on disk, left hanging a question the new documents had answered, and repeated an ask David had already made twice.
A call-to-action headline must survive being read cold, with no page above it, and must name what the buyer walks away with rather than what happens on the call. Write it, then read it as somebody who landed on that section from a scroll or a #anchor link with zero context. Any word the page itself had to teach is disqualified from the CTA headline. Where the page has already taught a method, tie the offer to a concrete step of it ("we do steps one to three, live, on your real org") instead of restating the jargon.
Why: The CTA is the one block on a prospect page where comprehension converts directly into revenue, and it is the block most likely to be read out of order because anchor links and scanning both land on it. Vocabulary the page invented reads as fluent to the author and as noise to the buyer, so the author is the worst judge of it.
Failure mode: Headlined the /blank call-to-action "Put your blanks on the screen". That is insider shorthand: it only parses if you already ran the exercise and already know "a blank" means an unowned seat. Worse, it describes an activity rather than an offer, so it does not say what the buyer gets. David: "WTF is put your blanks on the screen is that what we are really selling here? I dont even understand what that means." I could not see it because I had written two thousand words using "blank" as a term of art directly above it, so the word felt established to me and was not established for a reader who scrolls straight to the CTA.
Treat OOO-bot posts and carried-forward flags as claims to verify, not facts. Before reporting a person as out or an unblock as still open, check for resolving evidence (sent mail, David's own messages) and mark anything unverified as "status unconfirmed" rather than asserting it.
Why: Three wrong assertions in one briefing costs David correction time and erodes trust in the delta engine; the whole value of the morning pass is that carried flags are true.
Failure mode: Dan's 8/27 morning briefing reported stale facts as current: said Kristen's OOO started today (the Slack OOO bot posted 'Aug 27-31' but her OOO actually starts 8/28), flagged Nate as still lacking Accelo super admin (he has it), and flagged the PHORM member email as awaiting approval (Anna already sent it).
WOA Lafayette is EXCLUDED from portfolio rate, zero-dial flags, and all coaching, exactly like WOA China Grove and WOA Yadkinville (client dials own leads). The permanent CCM exclusion list is now: China Grove, Yadkinville, Lafayette.
Why: Zero-dial alerts on clubs we do not call for are false alarms that erode trust in the recap and could lead to coaching callers for leads that were never theirs.
Failure mode: Flagged WOA Lafayette as a zero-dial routing/coverage gap in the morning briefing and named it in the call center recap draft. Wrong: Sneeze It does not call Lafayette's leads.
When CCM activity columns read zero while Booked is non-zero, that impossible combination means an upstream writer stopped. FIRST check the Make dialer scenario queue (scenario 3607689, us2.make.com/1629595/scenarios/3607689/edit), not the sheet. Never conclude queued data is lost because a row failed to fill overnight: a stopped Make scenario holds its backlog indefinitely and drains in full once restarted. Treat concurrent "CCM Recording URL / AI Summary" scenario-stopped emails as the SAME incident as a dark CCM feed, not two separate flags. Make is read-only for agents, so diagnose and hand off to David or Bogdan to restart.
Why: The zero-dial pattern reads exactly like a caller-performance collapse; reporting it that way would have accused two callers of doing no work on a normal Monday when they had dialed hundreds of times. Calling the data permanently lost would have suppressed a legitimate team recap for days. One check separates both wrong answers: is the Make scenario stopped and holding a queue.
Failure mode: CCM showed zero Outbound Dials, Pickups, Conversations and 0.00 time on phone for 8/30 and 8/31 while Booked Appointments kept populating. I correctly called it broken rather than a real zero, but then reasoned from "the 8/30 row did not fill in over 24 hours" to "the data is not coming" and recommended holding the team recap indefinitely. Real cause: Make scenario 3607689 (org 1629595) had stopped with 397 dials queued behind it. The data was queued, never lost, and landed once David restarted the scenario.
When the ask is "there should be a button/panel on this page", the deliverable is that thing live on that page. Commit and push as part of finishing the work. Do not hand back a working tree plus a caveat. Check the repo's deploy path first: outreach-engine auto-deploys from a git push to main (no `railway up` — that is iJoin's flow), so there was nothing blocking the deploy.</what_to_do> <parameter name="why">A feature in a working tree is worth nothing to the person who asked for it. Reporting "not deployed" reads as a status update but functions as handing the last step back, and it cost David a round trip to find an empty page. Verify the deploy path rather than assuming a push is someone else's job.</what_to_do> <parameter name="agent">Claude
Why: Built two dashboard features (days-of-supply panel, Send test email button), verified them with a green test suite, then reported "NOT deployed" three messages running and left David to discover the missing button on the live page himself.
Failure mode: Built two dashboard features (days-of-supply panel, Send test email button), verified them with a green test suite, then reported "NOT deployed" three messages running and left David to discover the missing button on the live page himself.
When a report says "I got stuck on an error page", check the write that happened right before it, not just the page. Any invite/upsert that matches on email must refuse to touch accounts of a higher trust tier (staff domains) and must never lower a role as a side effect. Keep a dry-run repair script for the accounts already damaged.
Why: The visible complaint was cosmetic; the cause was an authorization downgrade that locked a team member out of the product. Fixing the 403 page alone would have left the trap armed for the next person.
Failure mode: Sneeze Studio's client-invite handler updated an existing user row in place. When Kristen invited her own kristen@sneeze.it as a test client contact, it silently demoted her staff account to client_reviewer and every staff page then showed a bare "Staff only". The reported symptom (no way home from a 403) hid the real bug (a write path that could change an account's role).
When announcing something new, the email says three plain things in order: what it is (a whiteboard page where you draw how a customer request moves through your company), what you do (draw boxes and arrows between two sticky notes, press one button), what you get back (Ollie reads the drawing and hands you a chart of who owns each step and the step nobody owns). Concrete nouns, the product's real name, the URL, then the one-line reason it matters. Cleverness comes after clarity, never instead of it. Read the draft as the recipient: if they could not describe the page to a colleague after one read, rewrite.
Why: A genuinely new thing gets no credit for mystery. The reader has never seen it, so implication does not land; only description does. Kennedy pattern-interrupts work on a known offer; on an unknown one they read as noise and the click never happens.
Failure mode: Claude wrote the OTP door touch-one email (and follow-ups) in an oblique, aphoristic register ("the third box is where the handoff has no name on it") and never said what the product actually is or what the reader would do and get. David: "written too obtuse and like crap, we shipped something truly unique and I can't make heads or tails what we are actually telling the customer."
An outreach sequence is a stage ladder, not a drip. Define the stages and the exact event that moves a person between them before writing any copy: prospect (on the list) -> MQL (engaged: visited the door, read a drawing, or replied) -> SQL (booked a call with Dawson or signed up) -> customer (paid, marked converted). Every email carries the booking link as a tracked CTA so a booking flows into the engine as an SQL automatically, and SQLs are handed to the sales pipeline (GHL) rather than mailed again. Copy states the offer and the two asks plainly: try the thing, or book 15 minutes.
Why: Without a booking link the sequence cannot produce an SQL, so nothing it does is measurable against the 50-signup goal. Without stage transitions, "MQL" and "SQL" are words on a slide, not states the system knows, and Dawson cannot work a pipeline that does not exist.
Failure mode: Claude loaded an OTP outreach "sequence" that was one cold email plus timed follow-ups, with no booking link, no path to Dawson, and no defined stage transitions. David: "there should be a link in the email that they can book so that we know it turned to an SQL... we need a real marketing sequence in outreach that moves people from MQL to SQL to customer, we are still playing around with bad email copy and CTA."
Every inbound sign-up on a product we run must be handled by the system, not by David asking. Three layers: (1) first sign-in with no linked customer auto-provisions a customer and links it, so nobody lands on a dead end; (2) every new sign-up fires a notification (ntfy + ticket) and shows in the admin as 'new sign-ups' until claimed; (3) the morning pass reconciles the auth provider's user list against linked customers and names any unlinked sign-up as a flag. When a prospect says they will sign up, verify the sign-up landed on our side within 24h rather than waiting for their email.
Why: A sign-up is the highest-intent signal a prospect gives. A dead-end page after 'Create your account' loses the deal silently, and no downstream agent can catch it because no state was written. Provisioning plus notification plus reconciliation turns an invisible loss into a visible queue.
Failure mode: iJoin self-serve sign-ups were silently dead-ending. A prospect (Val, Club 24) created a Clerk account on 2026-09-02 after a demo, landed on 'No club linked to this account', and never appeared in the iJoin admin because a Clerk user only becomes an iJoin customer when staff manually links it at /admin/tenants. Nothing notified anyone. Two earlier outside sign-ups (8/17, 8/28) had the same fate. The morning pass had Val flagged as 'responsive only' and waited for an email instead of checking whether the sign-up she promised had actually landed. David: 'this needs to be fixed for all users coming in not just when I ask, we would have missed this opportunity, big miss.'
A cold email to someone who has never heard of OTP opens with the category in the reader's own frame, before any feature, name or link: Sales has a CRM. Accounting has QuickBooks. Your meetings, where every real decision and promise is made, have a notebook and somebody's memory. OTP is the missing system of record for what your company says it will do: record the meeting, every decision and commitment becomes a line the company runs on (who owns it, when, whether it happened), and next Monday opens with last week's promises kept or not. Only THEN the taste (the drawing) and the call. Product nouns (Ollie, boards, seats) are earned by the category sentence, never assumed. The constitution's axiom is the sales line: accounting reconciles what you have, OTP reconciles what you say with what you do.
Why: A stranger cannot want a feature of a thing they cannot place. The CRM/accounting analogy hands them a slot in their head that is visibly empty, which is the whole pitch; without it, "draw your company and Ollie reads it" is a curiosity with no reason to exist.
Failure mode: Three drafts of the OTP cold sequence assumed the reader knew what OTP is. Touch one talked about Ollie, boards, seats and "your OTP account" to a construction-company owner who has never heard the name. David: "this is a cold email, they have no idea what OTP is and you are going on the assumption they do... rethink the cold email sequence as if they are coming into OTP new, never heard of it, revealing the main tenet: what if information from every internal meeting became part of the way your company operates. Sales has a CRM, Accounting has software, what about..."
Never interpolate a Date into a raw sql`` template in this repo; pass it as ${d.toISOString()}::timestamptz (there is now an `at()` helper in services/insights.ts). More generally: when every test runs on a different database driver than production, passing tests are not evidence the page works. Deploy, then run scripts/smoke-dash.ts inside the app container (railway ssh -s app -- sh -c 'cd /app && npx tsx scripts/smoke-dash.ts [paths]') to render the real pages against the real driver and the real data before telling David it is live.
Why: A dashboard page is behind magic-link auth, so "deployment SUCCESS + /health 200" was mistaken for verification. It proves the process started, not that any page renders. David found the 500 instead of the check finding it, which is the wrong order and the second time this repo has shipped a report page nobody had ever seen render.
Failure mode: The rebuilt /dash/reports page passed 425 tests, typechecked clean, and rendered correctly against a full snapshot of production data, then returned Internal Server Error the instant it was deployed. Cause: a JS Date interpolated into a raw drizzle sql`` template. The test suite and the snapshot renderer both run pglite, which accepts a Date parameter; production runs postgres-js, which throws "The string argument must be of type string. Received an instance of Date". Nothing in the local toolchain could see it.
A Resend/SES domain verified for sending is configured for sending ONLY: DKIM and SPF, no MX. Receiving is a separate switch (Resend Inbound) plus an MX record that must be the lowest priority on that domain. Before blaming a webhook or a tracking pipeline for zero replies, run `dig +short MX <sending-domain>` — an empty result is the whole answer. Set reply_to on every sender identity to an address on a domain that actually receives mail, so a misconfigured sending domain can never silently swallow the pipeline. The report now resolves MX itself (replyPathCheck in services/insights.ts) and names the broken link rather than guessing.
Why: This is lost revenue, not a reporting defect. Interested prospects were bounced for a month while the dashboard showed a 99.5% delivered rate, which reads as "the list is cold" and is really "the door was locked". A domain that cannot accept mail is also a deliverability and RFC-compliance liability on cold outbound.
Failure mode: SUCCESS: Outreach — found why 30 days and 10,761 delivered cold emails produced zero replies. Not a tracking bug: none of the sending domains (go.orgtp.com, mail.sneeze.it, mail.ijoin.ai, sneezeitdigital.com) has an MX record, and every sender identity has reply_to empty, so replies go to the From address on a domain with no exchanger. Every prospect who hit reply got a delivery failure. The Resend webhook was innocent throughout: enabled, subscribed to email.received, handler working.
Any autonomous service that occupies a seat on the chart must report from the machine doing the work, not from a human session about it. Give the service its own seat-bound token as one env var, and have it (1) push its KPI on a short timer, which doubles as the heartbeat because every MCP call bumps last_used_at, and (2) log_work at most hourly, and ONLY when the window contained real activity. Two mechanics make this cheap: OTP's Remote MCP is stateless, so a single POST carrying tools/call is a complete conversation with no initialize handshake and no SDK; and update_kpi upserts on (kpi, period), so pushing the same week 144 times a day overwrites one row. Never log an empty window, and never let a second tile be created for a number that already has one.
Why: A chart that goes dark on the days a system is busiest teaches everyone to stop trusting the chart, and a scorecard reading a frozen file is worse than a blank one because it looks alive. This is the difference between an agent seat as a box someone maintains by hand and a seat as a live report from the thing itself, which is the whole claim OTP makes about agents on the org chart.
Failure mode: The Outreach seat read as dead on the org chart while the engine was sending thousands of emails, and the weekly send tile read 0 against a real 3,250. Nothing was broken in OTP: an agent seat is lit by two facts that are both the AGENT's job (last_used_at on its own bound token, 15 minute window; rows in agent_runs from log_work), and sending mail touches neither. The seat was only ever lit by a human running a Claude session that logged on the engine's behalf, and the KPI tile read a laptop file that only refreshes when somebody remembers to run a script.
Never call a thread unanswered, dropped, or aging from inbox state alone. Before flagging anything as urgent: search Sent for that address over the relevant window (or read the full thread, not the quoted tail of one message), and check the calendar for any thread that proposed a meeting. Then state in the report what was checked, so the verification is visible rather than assumed. This is the inbox-specific case of the general rule that a point-in-time signal is not a live escalation until the resolution path has been checked.
Why: A false "your client has been waiting 8 days" is more expensive than a missed nudge: it burns the founder's attention on a resolved item and, repeated, it trains him to ignore the channel entirely. An email triage that cannot see the answer is an alarm with no off switch. It also nearly corrupted a fix-or-terminate hearing, where the fabricated catch was the headline argument for keeping the seat.
Failure mode: Pepper's inbox triage reported two urgent client escalations and both were false positives. It called a ReEnvisionMD thread "8 days unanswered" when David had replied 107 minutes after the question, and it flagged an HBFG call as a passed date when the call had run on schedule. The cause is that the triage judges "unanswered" from INBOX state alone: David's own sent mail is not in the inbox, and the quoted history inside an inbound message ends at whatever came before that message, so any inbound with no visible reply beneath it reads as a dropped ball. The report was written and handed over before any of it was verified.
Any agent that copies a number from a file to a dashboard must check the file's age before it copies, and refuse rather than launder. Add a max age per source (48h default for weekly KPIs; 18h is a briefing rule and too tight here), and treat STALE as its own outcome, neither a success nor a page: it is a real problem with whatever writes the file, not with the push, so it must be visible in the run summary but must not fire one alert per affected KPI. Seven alerts for one un-run upstream scan is how you train somebody to ignore the eighth.
Why: A missing number is visibly missing and prompts a question. A stale number wearing a fresh timestamp is invisible and gets acted on, which is why the registry's own note says a scorecard reading a frozen file is worse than a blank one because it looks alive. Nearly half of one founder's scorecard was in that state without anyone knowing, and it was found only because the seat was cross-examined about its actual charter rather than its run count.
Failure mode: Tally's charter is keeping the scorecard's numbers honest, and it was doing the opposite. Seven of its fifteen sources are files, five of them read from one Crystal file last written six days earlier. Tally pushed every one with the current period and the current timestamp and no staleness marker, so a 141-hour-old number arrived on the chart indistinguishable from a number measured that morning. The instinct was already half right: Tally refused to push when a file was MISSING or a live call FAILED. It simply never asked how old the answer was.
Never use first-match-wins priority over Meta action_type names that are not mutually exclusive. Read 'lead' first, which is Meta's own aggregate across all lead channels (96 form + 2 pixel = 98 here). Only if 'lead' is absent, sum the genuinely distinct channels (onsite_conversion.lead_grouped + offsite_conversion.fb_pixel_lead). Never sum every action containing the word 'lead' -- the *_add_meta_leads variants restate the same 96 and would treble it. Any metric derived from a vendor API where multiple field names could plausibly hold the answer must be spot-checked against that vendor's own UI for one real account before the number is shown to anyone.
Why: A reporting tool's only asset is trust, and it is spent the first time a client-visible number disagrees with the platform's own dashboard. A silent 48x error is worse than an outage: an outage announces itself, whereas this rendered a confident, plausible, precisely-formatted number that was wrong. David caught it by checking one account by hand, which is exactly the labour the product exists to remove.
Failure mode: Sneeze Coach reported beem Atlanta Glenwood at 2 leads / $942.70 CPL when Meta Ads Manager showed 96 leads / $19.64 CPL over the same window and the same spend ($1,885.39). Cause: src/services/meta.ts used a first-match-wins priority list (LEAD_ACTIONS) that ranked 'offsite_conversion.fb_pixel_lead' ABOVE 'lead' and 'onsite_conversion.lead_grouped'. The account had 2 stray pixel lead fires and 96 real on-Facebook lead-form submissions, so the first match returned 2 and the 96 were silently discarded. CPL was then off by 48x. Every lead-form client account was affected, and the wrong number flowed into CPL, the money lens, findings and the client-facing share page.
Score a SERVICE LINE (what the customer is buying), never an industry vertical. Treat the vendor category as a coarse gate only: strong categories count alone, weak ones count only when the business NAME carries service-line evidence (terms or a known competitor brand). Never drop a junk bucket wholesale either - a Restore Hyper Wellness 2.74 miles from beem is itself filed under 'spas', so the category is unreliable in both directions. Rank by evidence, not distance: the nearest hair salon is not more of a competitor to a sauna studio than a far one. Validate every configured category against the data source's actual vocabulary at boot so a typo fails loudly instead of silently narrowing a market. Above all, never let an unclassified client fall through to a default list - state that no service line is set, and report 'outside coverage' rather than zero when the query sits outside the data's footprint.
Why: A competitor list is read by a client, so a wrong one costs more than credibility: it misdirects budget and positioning advice. The failure mode is specific and repeatable - a taxonomy that looks careful and specific while quietly matching nothing, and a fallback that manufactures a plausible answer for a question the data cannot address. Both produce confident output with no error, which is the only kind of wrongness that survives review. The same discipline already applied to Coach's metrics ('a miss is not a zero') had never been applied to its market data.
Failure mode: Sneeze Coach's competitor list for beem Atlanta Glenwood was 150 rows led by Tatum da Barber, Bath & Body Works, Kidney Dialysis Spa Atlanta and a waist-trainer seller. Three defects in Sneeze Audience produced it. (1) The VERTICAL was used as the unit of competition: beem sells contrast therapy but was filed under 'wellness', so it was matched against every spa-ish listing within 10 miles. Drybar and Scissors & Scotch are both 'salon' and compete with nobody in common. (2) Overture's 'spas' and 'beauty_salon' are junk buckets - barbers, Bath & Body Works and a dialysis centre are all categorised 'spas' - and supplied 3,573 of 12,998 candidate listings across the roster. (3) Five configured categories (crossfit, boxing, climbing_gym, cycling_studio, massage) did not exist in an extract that has 13 categories total, so fitness had silently been 'gym' alone. Separately, an 'other' fallback gave a chicken restaurant 686 gyms-and-spas and a kids' performing-arts school 1,321, and a Costa Rica client read a confident 0 against a US-only extract.
After geocoding any business, verify the pin by looking for that brand's own listings in the reference data, and treat two kinds of 'nothing nearby' as different: brand present in the data but nowhere near this pin is an ALARM, brand absent from the data entirely says nothing and must not warn. Set the alarm threshold at wrong-region distance (100 miles), not the verification radius - a sparse footprint is not a wrong pin. Match brands on TWO words at TOKEN boundaries: one word as a substring matches 'champy' to 'ChampYon Martial Arts' and 'workout' to 'Ladies Workout Express', while a same-unit matcher is too strict and flags a client whose own sibling is 6 miles away. Never accept a false VERIFICATION to avoid a false alarm - a wrongly-verified pin silences the check for the exact client it exists to protect. For a multi-location account where no single point is meaningful, REMOVE the location rather than pick a representative site: a wrong pin is worse than a missing one.
Why: Every downstream number inherits the pin. Refining competitor selection, demographics or spend recommendations is worthless if the map coordinate is in the wrong state, and the failure is invisible because the report renders completely and plausibly for whatever town it was given. The general lesson is that a lookup returning 'not found' is data, not an absence to be discarded silently - the codebase already computed the answer and threw it away.
Failure mode: Three Sneeze It clients were having their entire market analysis computed for the wrong location, silently. Villa Sport Fitness (a $12,500/mo account) was pinned to Lower Manhattan, where VillaSport has no club - the nearest name match was 'Il Villaggio Nail Spa'. Workout Anytime China Grove was pinned to China Grove, TEXAS when the club is in China Grove, NORTH CAROLINA, about 1,000 miles away. Syufy was pinned to Syufy Enterprises' corporate head office, which is not a club. All three came from the roster builder matching a reference by place-name SUBSTRING. The code already had a function that looked for the client's own listing near the pin, but it returned null and said nothing when it found none - and null was exactly the interesting case. Competitor counts, reachable households and income index were all computed confidently for towns the clients do not trade in.
David, 2026-09-07: "beacon is not a sneeze it issue and EOS marks on SEO is not an issue it is a positive." Ranking for EOS queries puts OTP in front of exactly the buyers it wants -- people currently running EOS who need software -- and that concentration is evidence of product-market fit in the search data, not exposure. The trademark obligation covers what OTP CALLS ITS OWN FEATURES, not which queries OTP appears for. Keep those two separate permanently. Also: A3 only scans tickets, so run the company-mismatch test against every section Dan writes, including IDS items that are not yet filed.
Why: Treating an acquisition channel as a liability would have argued for destroying OTP's best source of qualified search visibility, on a board that should never have been discussing OTP in the first place. And a preflight assertion that only checks one surface gives false confidence about all the others -- Dan trusted A3's PASS while personally authoring the mismatch it could not see.
Failure mode: Dan made the Beacon KPI the #1 IDS issue on the Sneeze It AI Army board, framing 92.5% EOS-cluster search impressions as compounding legal exposure under the EOS Worldwide cease notice. Two errors. (1) WRONG BOARD: Beacon owns orgtp.com visibility, which is OTP product work and must never appear on the Sneeze It board -- the preflight's A3 company-mismatch scan reads tickets only, so it passed a violation Dan authored in the brief's own IDS section. (2) WRONG ANALYSIS: Dan conflated using EOS marks in OTP's own product naming and copy (the actual trademark compliance obligation) with RANKING IN SEARCH for EOS terms, which is legitimate competitive positioning and is a positive, not a risk.
Never divide media spend by a client's total POS enrollment count. Attribute using an auditable field inside the enrollment record. For Workout Anytime the proof is the PLAN NAME: joins on National Promo plans (STRONGER, BANANA) are reachable only through the national offer pages the ads point to, so they are traceable rather than estimated. Exclude kiosk-tagged plans. Corroborate against platform-attributed purchases; when two independent methods land close (836 vs 929 here), report the conservative one and state the range. Report a missed target plainly rather than picking a denominator that hits it.
Why: A blended CPA built on unearned joins collapses the moment a client asks which members came from the ads, and it discredits every other number in the report. The plan-name method survives audit because the client can query it in their own system. It also produced better strategy: it revealed that cost per lead ($29.26) was already inside target while conversion volume was the real constraint, which a fabricated $14.88 would have hidden.
Failure mode: Client performance reporting divided total media spend by ALL POS/iCart enrollments to produce a blended CPA, claiming credit for joins the campaign did not generate. The Workout Anytime August 2026 report claimed a $14.88 CPA by dividing $45,000 by all 3,484 iCart joins across 190 clubs, when iCart records every enrollment from every source including walk-ins, in-club kiosks, presale, eGym and club-level marketing. The provable figure was $53.83.
Name every funnel stage by what the system actually records. A POS or cart entry is an "enrollment start" or "checkout start", never a "lead", and its completion rate is a "checkout completion rate". Always show the stage ABOVE it so the high rate is contextualised: paid clicks 29,048 to enrollment starts 1,538 is 5.29%, and click-to-membership is 2.88%, which are the credible marketing numbers. Cross-check the stage against platform adds-to-cart (Meta 265 + Google 1,663 = 1,928 vs iCart 1,538) and say so in the report. Use realistic recovery rates for abandoned checkouts (15% to 25%), never 40%.
Why: David: "54.36% conversion is way too high, this has to make us look good so they continue the spend, realistic expectations." A number that looks too good gets the whole report doubted, which costs the account more than a modest number would. The fix also surfaced the strongest honest argument for continued spend: at $53.83 CPA against $37.19 monthly member revenue, payback is 1.45 months and the return is 4.1 to 1 even on a pessimistic six-month tenure. Member economics beat CPA-versus-target as the persuasive frame, and they require no assumptions.
Failure mode: Labelled a POS checkout-completion rate as a marketing lead conversion rate in a client report. iCart "All Entries" records are people who ENTER the join/checkout flow, not marketing leads. Reporting "1,538 leads converting at 54.36%" read as fabricated because no marketing funnel converts at 54%. The math was correct; the label destroyed its credibility.
That sheet has Budget Meta and Budget Google columns beside the Total. Pace against the share for the platforms actually measured, and name the denominator in the output. Trust the split only when Meta plus Google reconciles with the Total to within a dollar, since WOA Blairsville carries a lost decimal and WOA Corporate is 5000 short. A platform with a budget and no account attached is a finding, never folded into the other platform pace.
Why: A pacing percentage gets read aloud on client calls. The wrong denominator does not fail loudly, it produces a confident number saying under-delivering about an account that is 46 percent over its Meta budget, so the coach takes the opposite action. The same sheet feeds Dash.
Failure mode: Sneeze Coach paced Meta-only spend against the Total Budget column of the Client Budget and Coach Mapping sheet, which is Meta plus Google. Villa Sport read 117 percent of pace when its real Meta pace was 146. Rockstars of Tomorrow Corporate understated by 3.5x, Powerhouse Bridgeport by 2.25x. Five clients affected.
An ad account is not a client. Before attributing an ad account's spend to a client, run `google-ads.sh campaign-performance <id> 30` (or the Meta equivalent) and look at the campaign name prefixes. If two or more naming families appear, attribute at campaign level, not account level: query FROM campaign with a name filter rather than FROM customer. Only trust a prefix filter after checking the parts sum to the account total, so nothing is dropped or double counted.
Why: Account-level attribution is invisible when it is wrong: the number arrives formatted and confident, and every internal consistency check still passes because the account really did spend that. Holding companies routinely run several brands from one account, so this is not an edge case. The wrong number here would have been read to a named client contact.
Failure mode: SUCCESS: Sneeze Coach attributed a whole Google Ads account to one client. The "Syufy Ad Account" holds VillaSport's seven clubs (campaigns prefixed "WS | VS |") alongside a second brand's Boise-area campaigns (prefixed "SI |"). Reading the account whole put $13,945/mo of another brand's spend onto VillaSport and paced it at 219% of its agreed budget, a number a coach would have read aloud to the client.
When a system grows past its first instance, go up a level and check what still points at instance one. A default nobody chose is a bug. The entry point of a multi-item system must present the set, read off the registry so it cannot drift from what is routable. Every item states its own copy AND its own graphic, and the shared component keeps no defaults to inherit, so the next item does not compile without declaring its own.
Why: Speed to lead was never chosen as the headline offer, it was the first page anyone wrote, and the root redirect, the campaign links and an unrelated page's hero drawing all inherited that accident. Sneeze It sells iJoin, the visitor pixel, market intelligence, agent readiness, creative and coaching, and the front door advertised one of them. An inherited default is invisible precisely because nobody decided it.
Failure mode: Dirk built sales.sneeze.it and left the root as a 302 to /o/speed-to-lead, and shipped /o/market carrying the speed-to-lead bar chart. David: "sales.sneeze.it defaults to https://sales.sneeze.it/o/speed-to-lead". Same fault twice: whatever was built first became the default for everything above it, so a visitor was told we sell one thing and a page about choosing a site argued a different product's case.
When a correction is ambiguous and one reading means "keep doing the thing you were just criticised for", that reading is almost certainly wrong. Check it against what the person said earlier rather than parsing the sentence in isolation. Ambiguity about WHAT to sell is worth one question with concrete named options before writing, because guessing wrong burns a whole cycle and the copy has to be thrown away either way.
Why: Sneeze It sells the stretch after the click: the ads and their dead geography, the join flow (iJoin, ABC), the visitor pixel, and the calling. Speed to lead is one pillar of four. It led every campaign only because it was the first door page written, and three rounds of polish went into sharpening the wrong offer while the real differentiator went unsold.
Failure mode: Dirk kept both cold campaigns pitching speed to lead across three rewrites. David had already said "we do so much more than speed to lead but this is what you chose as the page?" and then "Gym owners... and Med spa owners... need to be rewritten stills selling speed to lead", which Dirk read as a constraint to keep, when it was the complaint itself. It took "we are not selling speed to lead!" before Dirk changed the offer rather than the wording.
In the shared ~/otp-platform tree, NEVER branch from local `main`. Branch from `origin/main` explicitly, in an isolated worktree: `git worktree add <path> -b <branch> origin/main`. Then verify with `git diff --cached --name-only` that exactly the intended files are staged BEFORE committing. If a contaminated branch already has a PR, do not force-push it clean: check `git branch -r --contains <sha>` first, because the other session's head commit may exist on no other branch. Leave it intact, open a fresh single-file PR, and close the old one with a comment naming the stranded commit so its owner can recover it.
Why: This repo has multiple Claude sessions working in it at once, and the swamp log records the same hazard three times (issues #29, #30, #35 all had to move to isolated worktrees). Branching from local main silently absorbs whatever another session has staged or committed, which turns a one-file PR into a review of someone else's unfinished work and makes CI failures unattributable. Force-pushing to fix it destroys work that exists nowhere else.
Failure mode: Radar cut a feature branch from a local `main` that another concurrent session had committed to, so the PR carried eight files (that session's join-link work and CSS) instead of the one file intended. It also inherited a design-lint failure that was not Radar's, and burned a CI cycle diagnosing a break in someone else's code.
On a Railway service whose repo has NO git remote (sneeze-coach, sneeze-audience, sneeze-studio, outreach-engine all work this way), Railway has nothing to pull and the ONLY thing that ships code is `railway up --service <name>` from the working tree. Setting or changing a variable in the dashboard restarts the container against the image it already holds, which is indistinguishable from a real deploy: downtime, recovery, green health check, credentials present. Always verify a deploy by requesting a route or string that exists ONLY in the new build, never by reading the deploy log or the health endpoint. Check `railway status` for the linked service first, and confirm the local branch is committed before running `railway up`, because it ships the working tree rather than a branch.
Why: The failure is silent and reads as success in every place a person normally looks. Believing the code is live when it is not means the next hour is spent debugging a feature that was never deployed, and in a client-facing tool it means telling someone a capability exists when it does not.
Failure mode: A Railway deploy looked successful and shipped nothing. All new env vars were set in the dashboard, the service restarted, /healthz returned 200 and every credential read as "set" - but every route added in the new build returned 404, because the running image was the pre-feature one.
When a pipeline reports a striking ZERO, verify the instrument end to end before drawing a conclusion from it: check that the field the conclusion rests on is actually populated, with a query like `count(*) filter (where length(col) > 0)`, not just that rows exist. Row count is not data capture. And when an expected field is missing, do NOT guess the provider's schema and write whatever the guess returns into the column: record the payload's SHAPE (keys, types, string lengths, nested key names, with long values reported by length only so a diagnostic never becomes a second copy of the content) and read the real shape on the next event. Guessing a field name risks storing headers or markup as prose, which is worse than an empty column because it looks like it worked.
Why: Two conclusions had already been drawn from the broken instrument: that cold outreach produced zero human replies, which nearly justified abandoning a channel, and that sourcing 5,000 more leads was the fix. A subject-only classifier files an interested reply with a "Re:" subject as "other" -- exactly one "other" existed and it was dismissed. A feature shipped the same day to read successor names out of reply bodies could never have fired, because there were no bodies. An empty column that still returns rows is the most expensive kind of silent failure: every downstream number keeps computing and stays confidently wrong.
Failure mode: The outreach engine stored 521 inbound replies with an EMPTY body, every single one. The webhook read `data.text ?? data.html` and neither key arrives from the provider, so sender, subject and timestamp were kept and the message itself was discarded. Nothing surfaced this because a reply row still looked complete. Every reply classification for the life of the system was therefore made on a subject line alone, and a "0 human replies across 19,884 sends" finding was reported off that instrument.
When one ad account is shared between two businesses, verify the campaign filter by matching LOCATIONS to the client's other platform account, never by checking that the parts sum to the whole. A partition proves a boundary exists and says nothing about which side is ours. Sneeze It's own campaigns carry the 'SI' prefix on both Meta and Google, so location names are the reliable join key. Before diagnosing a budget-vs-spend variance as either a stale sheet or client overspend, first confirm the spend figure covers only the locations under management. Run `npm run verify:prefix` in sneeze-coach, which scores every candidate prefix by location overlap with the Meta account.
Why: A 170% pacing figure would have driven a coach to open a client call apologising for an overspend the client was not committing — they were at 97%, exactly on budget. The client's own corporate ad spend was being billed to their performance report. Wrong-account attribution is invisible in a total and obvious the moment the account name and its filter are printed beside the spend, which is why any figure read through a filter must display its scope.
Failure mode: Sneeze Coach reported $48,583 media spend and 170% budget pacing for Villa Sport's August report. Real figures: $27,586 and 97%. The Google campaign filter for the shared Syufy account was set to 'WS | VS |' (VillaSport corporate's own 7 Athletic Clubs, which Sneeze It does not run) instead of 'SI |' (the 11 clubs we do run). It was justified by noting the two prefixes partition the account exactly, plus 'VS' resembling 'Villa Sport'. I initially framed this for David as 'is the budget sheet stale or is the client overspending' — both wrong; he corrected it to 'the spend is showing all locations, we just do a few, have to match the Meta ad locations to the Google spend'.
When ingesting a spreadsheet column of slash-format dates, detect the day/month order from the column's own data rather than assuming or declaring it: any first component above 12 proves day-first, any second above 12 proves month-first; if a column shows both or neither, leave the dates unparsed rather than guessing. Different tabs in the same workbook use different formats (CCM's Appts tab is ISO while New Leads is DD/MM), so detect per column, never per workbook. Separately: whenever a parser silently discards rows, that count must be rendered somewhere a human looks — a source that answers while returning a third of its data needs a 'degraded' state distinct from 'working', because a valid credential is not a working source.
Why: The failure was invisible in exactly the way that matters: every individual number was internally consistent, the credential was healthy, no error was logged, and the only symptom was ratios that looked absurd (5400%) or alarming (1% delivery) — which read as call-centre performance problems rather than as ingest bugs. Kettering was actually hitting 32% appointment rate against a 30% target with 82% show rate and 100% lead delivery. Coaches were being handed a false critical about a client that was performing above target.
Failure mode: Every call-centre figure in Sneeze Coach was a fraction of the truth for months. The CCM sheet writes dates day-first (24/12/2025) and Coach parsed them month-first, so 63.9% of New Leads rows (6,255 of 9,786) and 62.9% of Speed To Lead rows were silently dropped, and the survivors landed in the wrong month (5/8/2026 read as 8 May, not 5 August). This produced 'appointment rate 5400%' and a false 'only 1% of platform leads reached the call centre' critical on nearly every client. A row-count of the dropped rows already existed in code and no page displayed it, so the Sources page reported the credential as 'answering' throughout.
In iJoin, two distinct levels with distinct words: SUPER ADMIN (Sneeze It staff, every club) and CLUB ADMIN / club owner / club team (one club). Never show a club-level form or a default club under a super admin. Never default a select to a real club; use a "Choose…" placeholder. Refuse giving a super admin a club level in the route.
Why: A form that defaults to "Admin of Club 24" on a super admin row is a false statement of access. iJoin already had users confused about who sees what; an admin page that repeats the confusion makes it authoritative.
Failure mode: Built the iJoin /admin/users page with one "Set level" form under every person, defaulting to "Owner of… / Club 24 / Admin". Under a Sneeze It staff row that reads as "this person is Admin of Club 24". David pointed out these are super users who see ALL clubs, and that blurring super admin with club admin is the source of user confusion in iJoin.
A blast that already has sends keeps them: approval never recomputes an audience that exists. A trigger campaign has no audience filter and preflight skips the audience section for it. An unknown or falsy audience filter must resolve to nobody, never to everybody.
Why: The approval gate is the one place a person is told they are in control, and it silently replaced their 40 with 5,054. Failing open on an audience filter is the most expensive default in the system.
Failure mode: approveBlast recomputes the audience from the campaign's filter and inserts sends for it. A trigger-made blast already carries its audience (the 40 verified people the trigger chose) and its campaign filter is {all:false}, which computeAudience treats as everyone. When David pressed Approve on the OTP return batch, it went from 40 to 5,054 sends of the touch-two template to people who never came back, and 53 of them were mailed before Dirk paused it.
Never let a status default stand in for a classification. Stock a provider may be asked about must be positively tagged (a vertical an active pool rule routes) before it is counted, chosen or handed over. A rule with no vertical is a visitor rule and only fires on a company with a real page visit. The nightly run names the exact companies it chose; nothing else in the window is its business. Count provider spend on the object the run records, never on a helper's private counter. One run per brand per day across restarts, and cap companies per night while yield is unmeasured.
Why: Automated sourcing plus auto-send means a wrong classification is not a bad row, it is a delivered email from the brand's sending domain to a stranger. 38 off-ICP emails cost reputation on two domains and credits, and the dashboard said nothing had happened. Tagging stock positively and counting on the recorded object makes the failure visible before it mails anyone.
Failure mode: The first automated supply run in the outreach engine treated every company whose pool_status defaulted to 'unworked' as sourcing stock. 42 of those were Clay web-intent de-anonymisations with no vertical (a vet clinic, a law firm, a German machinery dealer, ct.gov, a food company). The run surfaced 20 of them, the visitor rules with no vertical filter matched them, LeadMagic found 38 people and 38 cold emails were delivered from mail.sneeze.it and mail.ijoin.ai before the run was killed. The run also reported 0 found and 0 credits while ~89 credits were spent, because the provider poster carried its own tally.
Never store a run's share as the limit. A cap is the period's total and lives in one place; a run's share is the remainder, computed each time from the cap minus consumption so far, and it bounds only that run's ask. If a downstream consumer holds the cap, hand it the period total, not the share. Test the second run in the same period explicitly: it must get exactly the remainder, not zero and not a fresh cap.
Why: A cap stored as a quota reads its own consumption as the limit. The failure is silent: the counters all look sane (cap 8, spent 12, asked 0) and the pipeline simply stops, which in an automated lead engine means a night of supply lost with nothing on the dashboard saying why.
Failure mode: The outreach supply job stored each night's run quota into the prospecting rule's daily company cap. A second run in the same sending day (after a deploy restarted the container) computed its quota as "what the cap has left" (8), wrote 8 into the rule's cap, and the planner then compared the day's consumption so far (12 already asked under that rule) against 8 and refused everyone. The run reported asked 0 with a healthy pool and no error.
Never write a new content format into a stored field until the renderer that reads it is live in production. Ship the reader first, confirm the deploy, then write data in the new format. When the data is something a person is about to act on, check what the currently deployed page will actually show before writing.
Why: Content and renderer deploy on different clocks. A field is read by whatever version is live right now, not the version on the branch, and the person who hits the mismatch is the one trying to do real work. Here it landed on the single most valuable message in the system, a reply to the first genuinely interested prospect.
Failure mode: I wrote a reply draft as HTML into the outreach engine's draft field while the live replies page still rendered that field escaped into a plain textarea. David opened the page to answer a hot prospect and saw raw markup tags instead of a usable message. The renderer that would have displayed it as formatting was built and tested but not yet merged or deployed.
Before treating repeat engagement at a company as buying signal, count how many contacts from that domain are on the list and how many were mailed. A handful of engaged people at a company where you hold five or ten contacts is interest. The same pattern where you hold hundreds or thousands is the mail gateway. Judge a domain on human replies and qualified leads produced, never on clicks or visits, and block a domain that has produced neither across thousands of sends.
Why: Link-scanning gateways generate exactly the engagement signature a pixel is built to detect, so the more thoroughly a large company is mass-mailed the hotter its fake signal looks. Acting on it wastes the founder's time on a lead that does not exist and, worse, sends the scarce personal outreach to the wrong person. It also silently inflates every "came back" number the dashboard leads with.
Failure mode: Ranking engaged contacts to find sales-ready leads, six people at one insurance brokerage looked like a buying committee: repeat site visits across separate days, exactly the pattern the system treats as proof of human interest. Checking contacts-per-domain first showed the list held 2,102 people at that one company, all mass-mailed, each with one or two visits on a single day. It was a security gateway clicking links, not interest. Across four such domains: 3,167 emails sent, 491 replies, zero human replies, zero qualified leads.
For every outcome the system claims to measure, prove the path end to end before trusting the number: call the receiving endpoint by hand, then confirm a real event from the source system actually arrives. A built and reachable endpoint is not a connected one. Check the source of truth, the calendar or the inbox, against what the dashboard says, and treat a permanent zero on an outcome metric as an unproven pipe rather than as bad performance.
Why: A zero on an outcome is ambiguous between "not working" and "not wired", and the second one hides forever because it never errors. Here it also had a downstream cost waiting: a booked prospect would never be promoted out of the cold sequence, so the engine would have kept sending pitch emails to somebody who already had a call in the diary. The same shape as the replies that silently bounced for months because no sending domain had an MX record.
Failure mode: The outreach engine has never recorded a single booking. The endpoint that receives them exists, is publicly reachable and works correctly when called by hand, and the dashboard has a column for meetings booked. Nothing was ever configured to call it. The calendar showed six real bookings in the same period while the system reported zero, and nobody noticed because zero is exactly what an outcome column shows before the first success too.
Build every screen against the real schema and the real empty state before styling it. A KPI screen must carry the full set of fields a KPI actually has (measure, unit, goal, direction, cadence, owner, source), must offer a manual path that never depends on prior data, and must work for an org with zero meetings. Looking right is half the bar, not the bar.
Why: A prototype that only demos is worse than none, because it wins agreement on a design that cannot be built as shown and hides the hard part until implementation. The empty state is the state 59% of OTP orgs are actually in, so any flow that assumes history is broken for the majority of real customers.
Failure mode: Built the Ollie page detail view as a visual shell with placeholder content: the KPI step rendered rock cards (owner + due date) instead of KPI fields, every flow assumed meetings already existed so a new org saw fiction, and there was no way to add a number or a teammate by hand. David: "A for looks D for functionality."
Before designing any replacement surface, inventory what it replaces: list every route, nav item and page in the existing product, and produce an explicit map of where each one lands in the new design (kept, merged, moved, retired). Agree that map before building UI. A redesign is an integration problem first and a visual problem second.
Why: A design that does not account for the existing product is not a redesign, it is a second product that has to be reconciled later at much greater cost. The reference screenshot shows one competitor's information architecture, not ours, and copying its nav imports their product's structure into a product with different nouns and more surface area.
Failure mode: Designed a replacement home page for OTP across a whole session without ever reading OTP's existing navigation, daily dashboard, or page inventory. Invented a five-item rail (Ask/Meetings/Scorecard/Chart/Flows) from the reference screenshot instead of from the product, so the new design does not map to how OTP actually works and silently drops most of what exists. David: "does not track to what OTP is... functionality a D."
Before any plan that adds leads, volume, lists or spend, show the engine converts: name the one results number (human replies and meetings per 1,000 delivered), its current baseline, and what is broken (audience, message, ask, follow-through). Fix and prove on a small batch plus the people who already showed up. Refill supply only after the number clears an agreed bar.
Why: Supply was a symptom. Volume multiplies whatever conversion rate the engine has, and a rate near zero times more names is still near zero, plus domain reputation burned, credits spent, and David's trust spent. This repeats the 9/9 "effort not results" correction (L335).
Failure mode: Asked how to make outreach a sales engine, I led with lead inventory: dead LeadMagic key as the Monday emergency, a 400/day per brand volume target, a $1,150/mo sourcing budget, a newsletter, a WOA list. All of it pours more names into an engine that produced 0 human replies across 12,300 sends. David: "adding more leads to the broken engine does not make it better."
Never treat site visits as proof of a person when the visits line up with send times. Before trusting engagement, check (a) whether the visits are clustered minutes after each delivery, (b) same-second hits on multiple links, (c) identical fingerprints across many contacts. Use replies, bookings, form fills and known relationships as the warm signal.
Why: A farming list or "who came back" report built on scanner traffic sends personal effort to machines and makes a broken engine look like it has 1,000+ warm leads. The real 30-day count was 1 human conversation in 21,996 delivered.
Failure mode: The outreach engine counted a contact as a "verified human" after visits on 2+ separate days. Of 1,033 contacts with visits on 3+ days, 680 share an identical day and page fingerprint with 10+ other contacts. Their visits land minutes after each touch is delivered, with two links opened in the same second. That is a mail security gateway scanning every link on each delivery, so a multi-touch sequence manufactures multi-day "returners."
When a customer re-confirms a cancellation, do not assume the cancellation is still pending. Either verify account status first or draft the reply as a plain confirmation that the account IS cancelled, with no promise of further steps.
Why: Promising work that is already done makes the customer think they are still active and invites a second follow-up from an already unhappy customer.
Failure mode: Good-morning pass drafted a Paris Wigs cancellation reply saying 'we are closing the account and phone number now' and flagged account closure as still pending. The account had already been cancelled since the original request; the draft invented open work from the customer's email alone.
Before calling a client thread unanswered, check sent mail by THREAD (not a single recipient address), covering every participant in the thread, and check David's Slack DMs for a handoff to a teammate. If a teammate owns the follow-up, report the owner, not a draft.
Why: A duplicate reply to a client who already heard from David looks disorganised and wastes David's attention on a decision he already made.
Failure mode: Good-morning pass flagged Club 24 (Val/Ed stuck in test mode) as unanswered and drafted a reply, but David had already replied on Sunday and Slacked Nate to follow up. The sent-mail check only searched to:club24val@gmail.com, missing replies to other thread participants, and never checked Slack.
Signals belong on the meeting in OTP before the meeting opens, not in a local file. During prep, post every signal to POST /api/v1/meetings/:id/headlines, prefixed [Dan]. Until agent keys are accepted, post through David's signed-in browser session, then confirm on the rendered meeting page that every signal appears. A prep file is a draft, not a delivery.
Why: David runs the meeting from the OTP page. Anything that isn't there doesn't exist at the table. This is the same class as the 9/7 to-dos that lived only in Ollie's follow-ups: work kept outside the system the reader uses is invisible.
Failure mode: Dan built the 9/14 Delta prep with 12 grouped signals but kept them in a local prep file (~/.claude/prep/delta-prep-2026-09-14.md). The meeting page in OTP showed zero signals from Dan, and David had to ask "I don't see any signals from you". The headlines API also rejects agent keys ("Sign in to manage headlines"), so no agent seat can post a signal to a meeting.
In every /l10dan prep, read the meeting's prepBrief from GET /api/v1/meetings/:id first. Treat every flagged item as a prep task: bring it on track or name the gap before the meeting. Set every rock's onTrack flag to the truth in prep; never leave a slipping rock marked on track.
Why: OTP already did the triage and emailed it to David. A facilitator who arrives without it is behind the person they're facilitating, and a stale on-track flag is the proxy-as-truth habit on the rock itself.
Failure mode: Dan's 9/14 prep never read the meeting's own OTP prep brief ("Before this meeting", emailed Sun 4:15 AM). It had already flagged Dan's rock as slipping (no update 19 days, a milestone overdue), record completeness missing 3 periods, expansion missing 3 periods, and the Tally tile 26 days without a value. The rock was still flagged on track, and David had to point the brief out mid-meeting.
A tile must show the answer on its face before anyone clicks, computed from the org's real record (e.g. how many numbers are outside goal and which). Clicking opens the work in place: the evidence, Ollie's read of why, and actions that change something or put it on a timer. Never hand a tile off to the chat with a ?q= prefill.
Why: A question is homework and a chat window is a detour; an answer with evidence and a next action is insight and actual work. It is also what makes the page worth coming back to and what makes scheduled, billable runs make sense.
Failure mode: Ollie Home capability tiles only open /ollie-chat?q=<question> in another page: the tile is a question the user has to go ask, it shows no answer, does no work, and the prefilled URL hand-off is lazy. My redesign proposal kept the question-card model and only rerouted it to a flow page.
A short thank-you or acknowledgement that asks nothing is a courtesy: file it, draft nothing, ping nobody, change no stage. A substantive reply puts the person on a hold (60 days, 180 after a clear no, measured from what they last said) during which no campaign, follow-up or trigger writes to them; when it lapses they are back in the pool and surface on Today with what they said, so a person writes first, holds longer, or lets the campaigns have them. A hold set by hand on the contact page overrides the rule.
Why: A draft that answers a closing message reopens a conversation the prospect just closed. And "replied once, silenced for ever" is the same mistake in the other direction: the follow-up marketing David wants to send afterwards never happens and nothing reports that it did not.
Failure mode: The outreach engine drafted a reply to Sue Boreskie's "Thank you David!" (a courtesy closing a not-now exchange) that asked her a question, and the underlying rule treated any human reply as "never mail this person again by any campaign" with nothing to say when that stopped being right.
When asked where a credential lives, grep every repo under ~/ (sneeze-*, ijoin-*, otp-*, moonbound, outreach-engine) by variable NAME and check Railway variables per service, not only ~/.claude. Extend meta-token-refresh.sh (or add a step) to push the new token to each Railway service that carries META_ACCESS_TOKEN, and keep a written list of those services in the script header.
Why: A token rotated in the local scripts but not on the deployed sites silently breaks Audience and Coach for clients at the next expiry, the same failure class as the July 27 four-places drift, now with a fifth and sixth place. The script's own header claims "everywhere" and it is not.
Failure mode: Dan told David the Meta Ads token lives only in ~/.claude/meta-ads.sh plus two MCP configs, and that meta-token-refresh.sh rotates it everywhere. Wrong: the same token is also set as an environment variable on deployed builds (Sneeze Audience, Sneeze Coach, and possibly others on Railway), which the refresh script does not touch.
When a build adds an OAuth redirect URI, the ship report must list the exact URI(s) to register on the provider console as a blocking pre-flight item, and env vars that gate a provider must be checked in Railway before calling the feature live. Any route a browser navigates to (OAuth start, callback) must redirect back to the page with a reason code, never reply JSON. When copying a reference UI that shows brand logos, vendor the real brand marks (Simple Icons SVGs, CC0) with alt text; never substitute initials.
Why: David tests within minutes of deploy. Each of these was visible on the first click and each was knowable before merge. A "live but dark" report that omits the console registration step reads as done when it is not.
Failure mode: Shipped Invite v2 with three rough edges David hit on first try: (1) a new Google OAuth callback URI that was never registered on the Google Cloud OAuth client, so the first click produced Error 400 redirect_uri_mismatch; (2) the Microsoft "Find teammates" button dead-ended on raw JSON {"error":{"code":"NOT_CONFIGURED"}} because the start route replied 503 JSON on a browser navigation; (3) the Slack / HubSpot / Microsoft Teams tiles showed the letters S, H, M instead of the brand logos, even though the Fireflies reference we were copying shows real logos.
Distinguish PENDING (source intentionally not live yet) from FAILED (source exists and errored). Mark registry entries with no live source as 'pending': true so Tally skips them quietly and never pages David; only genuine failures fire the ntfy 'high' alert. When adding a KPI whose source isn't ready, always set pending:true. tally.py now splits results into ok/pending/failed and alerts only on failed.
Why: Paging the founder for deliberately-not-yet-wired KPIs is alert noise that erodes trust in the alerting channel, especially during a recovery window where every phone buzz costs. Honest status (pending vs failed) keeps the scorecard truthful and the alerts meaningful.
Failure mode: Tally paged David with a 'high' priority phone alert ('3 KPI push failed') after Dan added Havok/Pulse/Dirk KPIs to the registry with sources that don't have data yet. Tally's alert logic counted any non-push as a failure, so intentionally-pending KPIs (blocked column, untested engine) triggered a high-priority alert.
When changing an OG/share image, change the URL (new versioned filename, e.g. og-image-v3.png), do NOT swap bytes under the same filename. Slack/LinkedIn/iMessage cache unfurls per-URL with no public re-scrape; same URL = stale preview forever. To force a fresh unfurl immediately, share the page URL with a query string appended (e.g. ?v=2). Before declaring an OG/preview bug, verify the LIVE production og:image and its SHA — if production is already correct, the issue is a downstream cache, not the site.
Why: David repeatedly returns to the same dark-card complaint because byte-swaps under a stable URL look fixed in the repo but never change what cached platforms display. Versioning the URL is the only durable fix; otherwise the loop repeats every review.
Failure mode: Recurring complaint: orgtp.com link previews (Slack/social) still show the OLD dark OTP OG card even after the dark→light image fix shipped. Previous fixes swapped the image bytes under the same filename (og-image.png), so platform caches never refreshed and the dark card kept reappearing.
Any list or dropdown that is FILTERED by membership/permission/scope must carry an escape hatch whenever the filter can hide something the user expects: a one-line "Don't see your team? You only see teams you're on; join or create it in Teams" with a link. Audit rule upgrade: don't just test empty states; walk the user's actual goal ("I want to create a meeting for team X") through every branch, including "the thing I'm looking for exists but is filtered out", and ask "did the user reach their goal?" repeatedly (~4 passes) before declaring a flow fixed.
Why: The craftsmanship is in the small details: a filtered dropdown with no explainer reads as "my team is gone" and the user is stuck exactly as if the list were empty. Empty-state auditing catches zero-cases; goal-walking catches filtered-cases. Both are required or the audit declares victory while the user still can't do the thing.
Failure mode: Claude's UX dead-end audit fixed the ZERO-team cases on /l8 (no teams at all, empty owner pickers) but missed the partial case David hit live: the New-meeting team dropdown only lists teams the viewer is a MEMBER of, so a team that exists but doesn't include you is silently absent, with no explanation and no path to fix it. A populated dropdown looked "working" to both the audit agents and me, so it was never treated as a possible dead-end.
When adding navigation (or any chrome), verify the config file is actually included by the live layout (grep for the partial's include) before editing, and verify the change end-to-end by rendering the layout, not just compiling the edited file. Retired files must be banner-locked the moment they are retired (dashboard-tabs.ejs now is). Also fixed the adjacent latent class: saved sidebar customization now merges via a tested invariant "a saved order is a preference, never a whitelist" (src/shared/sidebar-nav.ts) so items added after a user saves their layout still appear.
Why: Config edited in dead code passes typecheck, tests, and EJS compile while doing nothing -- the greenest possible no-op. Only rendering the real surface (or a render test on the LIVE layout) catches it. Same lesson family as L034: verify the user-visible outcome, not the artifact.
Failure mode: Two builder agents added "Your Ollie" and "Ollie Insight" nav entries to src/views/partials/dashboard-tabs.ejs, which is a RETIRED partial nothing includes (the live rail is the _sbItems array in layouts/main.ejs) -- so the menu items rendered for nobody. David caught it live ("how come /dashboard/insight is not on the menu?"). Neither builder nor my review verified the item actually RENDERED in a real shell.
WOA Lafayette runs their OWN call team, exactly like WOA China Grove. Exclude Lafayette from the CCM portfolio appointment rate, from uncalled/zero-dial alerts, and from every coaching recap. New leads with zero dials at Lafayette is EXPECTED, not a miss. Before flagging any project for zero dials, check it against the self-calling exclusion list (China Grove, Lafayette) and the Reporting-Active/Inactive status in Project Info.
Why: Coaching Amanda and Erica about leads they were never supposed to call destroys the credibility of the whole recap, and repeatedly surfacing a non-issue as the "one real issue" wastes David's attention every single morning. The China Grove precedent already existed; Lafayette was never added to it.
Failure mode: Arin/Dan flagged "WOA Lafayette: 3rd consecutive day of new leads with ZERO dials" as the day's one real call-center issue, and put it in the drafted team recap as something to tighten. It is not an issue at all.
WOA Yadkinville is NO LONGER CALLED by Sneeze It (David 2026-07-10). Treat it like ROT Frisco: excluded from the portfolio rate, from dial-volume trends, from drop-off alerts, and from every recap and DM. The deeper rule: a declining or zero dial count is NEVER evidence of a caller problem on its own. Before any project enters a recap as a "tighten" item, confirm it is Reporting-Active in Project Info AND not on the self-calling / no-longer-calling exclusion list. If a project's dials fall to zero, the FIRST hypothesis is that we stopped calling it, not that the callers slacked. When a correction removes one item, do not reflexively backfill the slot with the next-worst number to preserve a "3 wins / 3 tighten" shape -- a recap with two tighten points, or zero, is correct when that is the truth.
Why: Fabricating coaching points to fill a template destroys Amanda's and Erica's trust in every number Arin sends, and it burns David's attention twice: once to read it, once to correct it. The format is a container, not a quota.
Failure mode: After Lafayette was corrected, Arin/Dan immediately substituted "Yadkinville went from 17 dials Tuesday to 1 on Thursday" as a coaching point. Sneeze It is no longer calling Yadkinville at all. Two consecutive drafted recaps coached the team on projects they were never supposed to be dialing.
Never clear an exclusion at the LIST level when the exclusion is defined at the PERSON level. Open each individual's own bio page and check before contact. A competitor directory is not a filter, it is a starting pool. This generalizes: any time a guardrail is "never contact people of type X," verifying the source rather than the person is a false negative waiting to happen.
Why: A legal no-contact rule that gets enforced by proxy is not enforced at all. The whole point of a per-person hard stop is that group membership is a bad predictor of individual identity, and here it is demonstrably bad: people hold two credentials at once. One bad send under an active trademark notice costs far more than the minutes it takes to open a bio page.
Failure mode: Nearly sourced a cold-outreach list from non-EOS coach directories (Pinnacle Business Guides, Metronomics, Scaling Up) on the assumption that "not an EOS directory" implies "not an EOS person." It does not. Individual coaches are frequently DUAL-BADGED: Rick Appleby's own Pinnacle profile page lists him as "Certified Pinnacle Business Guide & Business Coach/EOS Implementer." Given the active EOS Worldwide trademark notice and the permanent no-contact rule, sourcing at the directory level would have put EOS Implementers on a Sneeze It send list.
When David says "the 5 cold emails" or "the sequence," default to a MULTI-TOUCH SEQUENCE (email 1 through email 5, with delays), not five separate one-to-one emails. His outreach runs Clay (enrichment) -> GHL (send) at LIST scale. Copy must therefore be built on MERGE FIELDS with safe fallbacks, not on hand-verified per-person research. Ask which shape he means before doing expensive research: a sequence and a batch of bespoke emails are completely different artifacts, and the research cost of the bespoke path is wasted if the answer is "sequence."
Why: Hand-personalized copy cannot be run through a Clay-to-GHL pipeline. It does not scale, it does not template, and the deep per-person research that makes it good is exactly the thing that makes it unusable at volume. I burned five parallel research agents producing an artifact with the wrong SHAPE, even though the underlying strategic insight (sell the read, not the product) was correct and transfers. Confirm the artifact's shape before paying for its contents.
Failure mode: David asked to rework "the 5 cold email outreach for OTP." I interpreted "the 5" as five hand-personalized one-to-one emails to five named coaches, and spent a full research cycle sourcing and verifying five real people, then wrote five bespoke emails quoting each person's own podcast episode and blog post. That is not what he wanted. He wanted a 5-EMAIL DRIP SEQUENCE (a multi-touch cadence) that he can load into Clay and run through GHL to a whole list.
There are THREE copies of the Google Ads API version constant, not two. When Google retires a version, bump ALL of: (1) mcp-google-ads/google_ads_server.py API_VERSION, (2) scripts/billing_pull_spend.py GA_API, (3) google-ads.sh API_VERSION. Grep for the pin before assuming coverage: grep -rn 'API_VERSION\|GA_API' ~/.claude. And treat "zero Google accounts / $0 Google spend" as a VERSION FAILURE until proven otherwise, never as a real zero. Verify by probing versions directly against listAccessibleCustomers and comparing HTTP codes.
Why: Every one of these wrappers fails the same way: it converts an API error into an empty success. A silent zero is far more dangerous than a crash, because downstream consumers cannot tell the difference between "no data" and "broken". The same bug already caused a $1,070 client underbill in June via billing_pull_spend.py. Here it would have deleted nine paying clients from the coach report, including the one David explicitly asked me not to forget. A pinned version in N places is a landmine with N triggers, and the count of N is itself unknown until you grep.
Failure mode: /coach-report returned "No enabled accounts found" for Google Ads and would have shipped a report with EVERY Google-only client missing (J&K Engines, Meyer Law, M.V. Parker Law, Jet City Blinds, True Path, Lazzara Law, Studstill, GettaMeeting, WOA Hartwell). Cause: `~/.claude/google-ads.sh` pins API_VERSION="v20", which Google has sunset. The wrapper swallows the API error into an empty resourceNames list, so a hard failure prints as a benign empty result. This is the THIRD independent copy of the Google Ads version constant: the MCP server and scripts/billing_pull_spend.py were both bumped v20 -> v21 on 2026-06-18, but google-ads.sh was missed because nobody knew it existed as a separate pin.
Work the command's steps in order and explicitly account for EVERY step, including the optional ones. If a step is skipped, the skip must appear in the terminal summary and in the report itself ("Search Atlas MCP unavailable — SEO data skipped"), never silently. Before declaring any multi-step command complete, re-read the step list and tick each one off against what actually ran. When a mid-run bug hijacks attention (like the Google Ads version failure), note the position in the checklist before chasing it, and return to that position afterward.
Why: A silently omitted section is indistinguishable from "that client has no SEO data" — the exact same failure mode as the Google Ads silent zero I had just finished diagnosing and lecturing about in the same run. An incomplete report that announces its gaps is honest and usable. An incomplete report that looks complete is a lie the reader cannot detect. I reproduced the very bug I had just fixed, one layer up.
Failure mode: Ran /coach-report and silently skipped STEP 2.5 entirely (Search Atlas / OTTO SEO data + rank tracker keywords). Shipped 46 cards with zero SEO sections, no SEO badges, and no keyword tables. Worse than skipping it: I never LOGGED the skip, so the report looked complete and correct. David caught it, not me. I got absorbed in the Google Ads v20 bug and never came back to the step I had jumped over.
Issues come from the OTP board and David picks them. Do not generate a candidate issue list from local files or from scanning signal. Apply this test before calling anything an issue: an ISSUE needs discussion to solve because it has no known owner or no known path. A TODO has a known owner and a known action and just needs doing. If it fails that test it is not an issue. Never escalate an agent's own uncertainty about how to read a data source into a claim that the data source is broken.
Why: Working a self-invented issue list wastes the scarcest resource in the room, which is David's attention in a live working session. Two of the three items were already resolved or misclassified, so the session would have opened by re-litigating settled work. The stale issues.md, last really updated 2026-03-12 and full of retired agents and departed staff, is not a source of truth and must not be treated as one.
Failure mode: Dan proposed three IDS issues by inferring them from a stale local issues.md and from inbox/Slack signal instead of from the live OTP board. All three were wrong: the Accelo-to-Trello migration was already done, the Make/Zapier ownership gap is a todo not an issue, and the CCM data-quality concern was stale because the data is correct. Dan also escalated his own uncertainty about a spreadsheet's column semantics into a claim that the data source was untrustworthy.
Two standing rules for OTP frontend work: (1) never refresh via location.assign(current URL) — use location.reload(); any anchor-navigation page makes assign() a silent no-op. (2) When a fetch helper is designed to never throw, every caller MUST branch on the {error} result — audit all handlers for ignored results whenever one instance is found, and never run a destructive follow-up (delete) without checking the preceding create succeeded. Full 61-handler audit + 15 fixes shipped in PR #207.
Why: One character-level bug made every meeting action look broken and eroded trust in the product's core loop (the Delta Meeting). The ignored-result pattern was silent data loss. Both are recurring classes, not one-offs — the audit found the same defect in 15 places after fixing it in one.
Failure mode: Meeting page actions silently froze during Delta Meetings: convert-issue stuck on "Creating...", to-do saves never closing, edits invisible. Root cause: reloadKeep() used location.assign(location.href), and every agenda chip is an anchor — once a #fragment is in the URL, assigning the same URL is a fragment navigation, not a reload. Compounding it, the page's api() helper never throws, and an audit found 15 handlers ignoring {error} results (one deleted an issue even when the to-do it converted to failed to create).
Add HiTone Fitness - Fayetteville, GA to the CCM exclusion list. Exclude it from portfolio appointment-rate math and never credit its bookings to Amanda or Erica. More generally: before crediting any project's booking as a caller win, confirm Sneeze It actually dials for that project. A project having rows in Project Stats does NOT mean we call it, because client-self-called projects also log rows.
Why: Praising the team for a booking they did not make destroys the credibility of the whole recap, and inflating the portfolio denominator with projects we do not dial makes the appointment rate meaningless as a coaching signal.
Failure mode: Arin included HiTone Fitness - Fayetteville, GA in the CCM portfolio totals and praised a Fayetteville booking as a team win in a draft Slack recap. Sneeze It does not call for Fayetteville GA. The exclusion list Arin was working from only named China Grove, Yadkinville, Lafayette (client self-calls) and ROT Frisco (inactive).
The "only Sneeze It" boundary applies to EVERY utterance in the Sneeze It L10, including Dan's own headline round -- not just the board and the signals. Before speaking in a meeting, check the subject of each sentence against the meeting's company: if the win, metric, or example belongs to OTP, it does not get said here, even about yourself. Dan's Sneeze It headline material comes from the Sneeze It board: Crystal's project count, Havok attribution, Arin's CC rate, CCM leads, the company rocks, client wins. Also: capturing a learning mid-meeting does NOT reset the context bleed that caused it. After capturing a context-boundary learning, re-read the next message being drafted against that boundary before sending, because the pull of recent work is stronger than the freshly-written rule.
Why: Three corrections on one boundary in a single meeting is not a slip, it is a demonstration that recency beats policy: Dan had spent an entire prior session inside OTP engineering, and that context kept resurfacing even after being explicitly named and captured. The practical consequence is that David spent his meeting correcting his facilitator instead of running his company. A rule that gets written and then immediately broken is worse than no rule, because it creates the appearance of a fix where the behaviour is unchanged.
Failure mode: Dan captured L074 ("OTP work is not a Sneeze It signal and does not go on that board") and then violated it TWO MESSAGES LATER, giving a Sneeze It personal/business update whose content was entirely OTP (mobile reflow at 390px, the orgtp.com/demo fix). Third correction on the same underlying miss in one meeting. David: "ok one more correction only Sneeze It".
Verifying that a write round-trips through the API is NOT verification that the user-visible thing changed. When the request is phrased against a URL or a page ("add this to the page at <link>"), the acceptance test is the RENDERED PAGE, not the database field: load the page (or its rendered HTML) and confirm the text appears where the user pointed. Specifically for Ollie Insight: meeting.aiSummary is the carry-forward that renders in the NEXT meeting under "FROM YOUR LAST MEETING"; the current meeting's Ollie Insight panel is produced by the ollie-insight service (src/routes/api/ollie-insight.ts) and must be generated through that path. Before writing, identify which surface renders the target and confirm the field feeds it.
Why: A 200 plus a read-back proves the API worked, which is exactly the evidence that makes a wrong-surface write feel finished. Dan reported success with real verification attached, so the confidence was unearned rather than absent, which is more misleading than saying nothing. The user then spent his own live meeting time discovering the failure and doubting his own eyes. Where a request names a URL, the URL is the spec.
Failure mode: Dan was asked to add the Ollie Insight to a specific meeting page and wrote it to that meeting's aiSummary via PUT /api/v1/meetings/:id, then reported it done after verifying the field round-tripped. The page did not change. Two distinct panels exist: "FROM YOUR LAST MEETING" renders the PRIOR meeting's aiSummary (carry-forward), while the current meeting's "Ollie Insight" panel is a generated read from the ollie-insight service and showed "No read yet" with an "Ask Ollie for a fresh read" button. aiSummary on meeting N is next week's carry-forward, NOT this week's insight. David had to screenshot the unchanged page and ask "am I high?".
For an agent-run meeting, the flow is: write the meeting record, POST /meetings/:id/agent-record, then POST /meetings/:id/ai/followups, which produces the insight through the normal pipeline with receipts. Never hand-write aiSummary. More generally: before concluding that a capability does not exist or that a human must do a step manually, search the codebase and recent PRs for the capability by NAME. Agent-facing plumbing that David built is often the exact thing being declared impossible, and the prior meeting's Ollie Insight had already named this one ("shipped the agent-record path"). A prior insight is a capability inventory, not just narrative.
Why: Declaring something impossible is a strong claim that ends the search, and it was made about a feature the user had personally shipped two weeks earlier and flagged as a signal. That is worse than a wrong answer: it tells the founder his own investment does not exist, and it pushed manual work back onto him inside his own meeting. The generated path also carries receipts, which the hand-written paragraph could not, so bypassing it degraded the product's core promise while claiming to serve it.
Failure mode: For an agent-facilitated L10, Dan hand-wrote an Ollie Insight, PUT it directly into meetings.aiSummary, and when that did not render told David the read could not be produced by an agent ("you click the button"). The purpose-built path already existed and David had shipped it himself on 7/6 as PR #154 for precisely this case: POST /api/v1/meetings/:id/agent-record submits the written record of an agent-run meeting, OTP redacts and persists it exactly like a confirmed transcript, and POST /api/v1/meetings/:id/ai/followups then generates the to-dos, issues, headlines and the insight, writing aiSummary itself. There is even a wrapper, ~/.claude/otp-meeting.sh record. David had to point at his own shipped feature: "I had this as a signal: Agent-record path shipped (PR #154), so Ollie can now read agent-run meetings."
A meeting rating that drops after the same feedback was already given means the corrective was cosmetic. Treat a repeat sub-5 rating as a STOP: do not propose better intentions, change the mechanism. Concretely, before the next Delta Meeting Dan must (1) open the actual meeting the human will see and confirm every section renders real data, fixing or filing what is broken BEFORE the meeting, (2) generate the Ollie Insight in advance via the agent-record then followups path so the room opens with a read already present, (3) verify the board's company matches the meeting's company, and (4) arrive with a written agenda listing only sections that are working. The measure of prep is that the meeting contains no discovery. If a section cannot be made to work beforehand, say so in one line at the top rather than finding it live. Also: running one pre-flight correctly (Tally) does not constitute prep and must not be mistaken for it.
Why: The first 4/10 was a warning and the 2/10 is the confirmation that nothing structural changed. The cost is not the rating, it is that the founder's scarcest hour was spent watching his facilitator debug itself, in a meeting whose purpose was his company's rocks and numbers. An agent that consumes the meeting it was built to run is worse than no facilitator, because the human still has to do the facilitation AND absorb the correction overhead. This is the seat's core deliverable failing twice consecutively, which is a seat question, not a tuning question.
Failure mode: David rated the 7/20 Dan L10 a 2 out of 10: "nothing got done, nothing is right, kind of a waste of time today." This is DOWN from the 7/13 meeting's 4/10, whose stated cause was "we are fixing the meeting within the meeting with an absence of information. The work happens BETWEEN the meetings and this is not the case here." Dan received that exact feedback, had standing rules covering it (L299 signals-first, L310 scan live OTP, L332 walk the meeting before the meeting, L376 Tally pre-flight), executed only the Tally pre-flight, and then spent David's meeting on discovery and self-correction: five corrections in one sitting, a broken scorecard found live rather than before, an Ollie Insight written to the wrong surface, and a capability declared impossible that David had shipped himself. Zero to-dos set, zero issues worked, one ticket filed.
Sweep ALL session transcripts by concept and synonym (blank dashboard also means first-run, empty-state, cold start, isNewOrg, needsOnboarding) across every model and subagent log before reporting that something was never said. Never conclude no-record-exists from a single artifact; name the sources searched.
Why: Work here is produced across many sessions and several models, so a negative answer scoped to one file reads as authoritative and quietly discards real prior work. Same failure mode Rule 19 (grep before generate) exists to prevent.
Failure mode: Asked to recall a high-priority item from a Kris meeting, I searched only the obvious artifact (the Kris transcript and that one session), found nothing, and asserted no record existed. It did exist, in a different session produced by a different model (Fable 5, the 2026-07-17 four-workstream UI overhaul). David had to correct me twice.
(1) Never filter ad accounts by current status when computing BILLING for a past/current period -- spend already incurred is billable regardless of whether the account is now disabled, unsettled, or closed. Pull insights for every account and let the spend value decide. Status filters are for "is this account live", never for "what did it spend". (2) Fail loudly on API/pagination errors in the account enumeration; a swallowed error silently truncates the account list and understates billing with no warning. (3) When quantifying a billing error, separate "the pipeline silently lost this" from "the pipeline correctly flagged this for a human decision" -- only the former is an error. Check the Review tab output before claiming an amount was missed.
Why: Billing accuracy is paramount and understated spend is silent lost revenue -- an account with a payment problem is simultaneously the most likely to be dropped by a status filter and the most likely to need invoicing. Verified scope on the 2026-07-23 run: 42 of 44 client rows were correct; only Powerhouse was wrong ($0 -> $514.63 spend, $0 -> $80 billing). Overstating the error size to the CEO on a sensitive billing document is its own failure -- it destroys trust in the audit as much as the original bug does.
Failure mode: Billing report (/billing-report) reported Powerhouse Gym at $0 while the client had live Meta spend. Root cause: billing_pull_spend.py filters the Meta account list with `account_status == 1` BEFORE pulling insights, so an account that spent money earlier in the month but is currently disabled/unsettled (Powerhouse went Active -> "Payment Needed" on 2026-07-17 after a card failure) is dropped from the spend file entirely -- it does not even appear as a $0 row. The same filter feeds the unbilled-spend sweep, so the account was invisible to BOTH the billing rows and the Review tab. Secondary failure: when first diagnosing this I overstated the impact as "~$144 of billing missed" by adding the client's unmapped Google spend ($925.66) to the Meta miss, when the Google line had in fact been correctly surfaced on the Review tab as a pending David decision. True billing error was $80.00.
(1) Before inventing a NEW client row for an unmapped account, check whether an existing client row already covers that entity -- multi-location clients (Rockstars, WOA franchises, beem, GLO30) often bill everything to one row, and corporate/HQ ad accounts belong on an existing location row rather than a new "Corporate" row. Ask which existing row, do not default to creating one. (2) The Clients tab column order is A=Client, B=% Ad Spend, C=Mode, D=Override $, E=Google Account IDs, F=Meta Account IDs, G=Manual Platforms, H=Notes. Google IDs go in E, never D. Verify by reading the row back before running the pipeline. (3) When David says "map the ones that are close and any others," finish the whole set in one pass and only hold back items where a wrong guess would materially overbill a client.
Why: Billing is David's most sensitive artifact and he was already frustrated at token waste from repeated correction rounds. Each avoidable round trip on a billing doc costs trust, not just tokens. The column-placement error in particular was silent -- the rows computed $0 with no warning, which is exactly the failure mode that started this whole investigation.
Failure mode: On the billing rebuild I created a standalone "Rockstars of Tomorrow Corporate" client row for Meta act_696250854945167 instead of mapping it to the existing Rockstars of Tomorrow Vegas row, and I flagged Workout Anytime Lakewood Park as an unknown arrangement when it is a known Sneeze It client. Both forced David to correct me on a task he had already told me to just finish. I also burned a full extra round trip by writing three Google account IDs into column D (Override $) instead of column E (Google Account IDs) on the Clients tab, which made those rows compute $0.
Two rules. (1) After any push that opens a PR, verify the remote head equals the local HEAD sha (git ls-remote origin refs/heads/branch vs git rev-parse HEAD) before creating the PR; never push refspecs of the form otherbranch:target when HEAD is a different branch. (2) "Deployed" is verified by probing for the CHANGE's own fingerprint (a string, a behavior, an endpoint), never by sha match alone; a sha only proves a deploy happened, not that the intended change is in it.
Why: Refspec pushes silently ship whatever the named ref points at, and a squash-merged empty-diff PR looks green end to end (checks pass, auto-merge fires, sha matches prod). The only step that catches it is content verification, and skipping it cost a full test cycle and a wrong "deployed" claim to David.
Failure mode: Claude shipped PR #358 believing it contained the Composio OAuth-return fix, then confirmed "deployed" by matching the prod commit sha. The fix was committed on a freshly checked-out branch, but the push used the refspec push oldbranch:newremotebranch with the OLD branch name, so the PR contained a stale duplicate of the previous commit and the actual fix never left the machine. David tested on prod and the bug was still there.
When David asks a scoped product question (where does X go, what should X be called), answer inside the existing structure at the same altitude as the question. Propose the minimal change list first; expand scope only if he asks for the bigger vision.
Why: Scope inflation on design questions wastes David's attention and buries the actual decision. A placement question needs a placement answer; a mock that redesigns everything makes the one decision harder to see, not easier.
Failure mode: Asked where Ollie belongs in the OTP nav, Claude produced a full interactive mock that reinvented the entire OTP menu experience (command palette, dock, context invocations, insight relocation) instead of answering the narrow placement question David actually asked
Never accept a clean result from a scan whose only evidence is the absence of output. Verify with a second, structurally different method — for a text scan, a literal `split(term).length-1` substring count alongside the regex pass. Prefer writing the scan to a file over inline `node -e` with nested quoting. This applies to any gate whose passing condition is silence: compliance scans, lint greps, security sweeps.
Why: A gate that fails open is worse than no gate: it converts "we did not check" into "we checked and it was fine," and the outward-facing send proceeds with false confidence. The trademark deadline was the next day.
Failure mode: A /swamp compliance scan reported "clean" when it was not. The regex scan was written inline via `node -e` with a template string, and the shell/JS escaping consumed the `\b` word-boundary anchors, so every pattern silently failed to match. A literal substring re-check on the same text immediately found an EOS Worldwide agenda mark ("headlines") in an entry about to go to 48 customers.
Never let In The Swamp undersell the week. The newsletter's entire promise is "we ship every week" -- its own header reads "30 weeks deep in the swamp / We shipped. Again." -- so a thin issue in a heavy week contradicts the brand it rests on. A major narrative entry does NOT substitute for the shipping haul; they reinforce each other. Operationally: at /swamp step 3, never treat a small changelog window as the answer. Cross-check it against `gh pr list --state merged` for the window, and if customer-facing work is unlogged, reconstruct the entries BEFORE presenting at the gate, recommending the full haul rather than the thin issue.
Why: This is the second consecutive week the changelog undercounted what shipped, and the first time it actually reached customers. The deeper fix is writing changelog entries when features ship rather than reconstructing them under time pressure on send day. An agent that spots a problem and then argues its way past it is worse than one that never spotted it, because the human trusts that the gate was real.
Failure mode: In The Swamp #30 shipped to 48 customers saying "2 new things this week" during a week in which 41 PRs merged (calendar auto-join, Ask Ollie and Projects going GA, 83 integrations, the meeting recap page, and more). I detected the gap at the approval gate and then actively recommended sending the thin version anyway, arguing the constitution entry was "a stronger story undiluted." David's verdict: "with all the work we did this week this issue is lame!!!lame,Lame!"
When working in a git worktree, never run `gh pr create` from a different directory and never trust the returned number implicitly. Always pass `--head <branch>` explicitly, and immediately verify with `gh pr view <n> --json number,headRefName,title` plus `gh pr diff <n> --name-only` that the PR is yours and touches the files you changed, BEFORE merging. Same check before any `gh pr merge`: confirm headRefName matches your branch. A returned URL is not proof a PR was created -- gh silently returns the existing PR for the current branch.
Why: Two real harms: another session's PR was merged without their intent, and a false "merged and live" status was reported to David for work that did not exist on main. In a repo with concurrent agent sessions and shared worktrees, the current directory's branch is not a safe implicit argument, and a merge is not reversible by simply noticing later.
Failure mode: Ran `gh pr create` from the shared repo directory (/Users/dsteel/otp-platform) while my actual work lived on a branch in a separate git worktree. The shared dir was sitting on ANOTHER session's branch, so gh did not create my PR at all -- it printed that branch's already-existing PR URL (#392). I read that as my PR number, then merged #392, which merged a different session's unrelated work. My own changes were never PR'd and never reached main, and I reported to David that they were merged and live.
An agent's knowledge of its own product's capabilities must be STATED in context on every turn, not left to be discovered through tools. A tool only helps if the model reaches for it, and a model never reaches for a tool covering a capability it is confident does not exist. Two fixes are required together and neither is sufficient alone: (1) a standing context block asserting the capability plus current state, which must never be omitted in the empty case, since the org with nothing set up is exactly the one about to be told the feature does not exist; (2) tools that let the agent act on the knowledge, including a discovery/list tool, because a capability an agent cannot enumerate is one it will guess identifiers for. Also put a guard in front of any rule that lets the agent say "the product cannot do that": having no tool for X is not evidence that X does not exist.
Why: Denying a shipped feature is worse than admitting uncertainty: the customer walks away believing the product lacks the thing they were about to pay for, and the false denial is logged as a feature request, so the team reads demand for something already built. This is the same failure class as the 2026-07-29 connections bug (Ollie saying he did not know about a connection that existed) one layer up, which means it is a recurring shape and not a one-off: every capability an agent has is a capability it can confidently deny.
Failure mode: Ollie told a customer OTP has no cron or recurring-automation feature and offered to file it as a feature request, when scheduling had already shipped (Processes Phase 2b: hourly/daily/weekly cadences, timezones, a poller, wallet gating). Ollie had no scheduling tool and no standing statement that the capability existed, so he answered a product question from memory and denied a shipped feature to a paying customer.
Ship the renderer in the same change as the agent that writes for it. If an agent is instructed to write Markdown, the surface must render Markdown, and that renderer must escape every byte BEFORE emitting any tag, because the text is model output plus tool output quoted back and neither is trustworthy. Whitelist link targets to internal paths and http(s) so javascript:, data: and protocol-relative URLs degrade to plain text. Render on every streamed chunk rather than only at the end, and make the renderer tolerate half-arrived Markdown, since every intermediate frame is partial. Also instruct the agent to LINK every place it names: an answer that mentions a page without a link converts an answer into a chore.
Why: Formatting is not cosmetic when it is the whole interface: raw asterisks read as broken software and undermine trust in the answer's content. And an unlinked destination is where a good answer stops being useful, because the user still has to find the page. Both faults survived launch because the prompt and the view were changed by different people at different times, with nobody owning the seam between what the agent writes and what the page renders.
Failure mode: Ollie's answers reached the chat page through textContent, so the Markdown he writes was displayed literally: users read "- **Linear is not connected**" with the dashes and asterisks in it. Separately, his answers named destinations ("connect it in Settings, Integrations") without linking them, leaving the user to go hunting for the page.
When a permission or filter function branches on an enum, make the fall-through explicit and test every enum value, because a value added later inherits whatever the final branch happens to be. Silent fall-through to the STRICTEST branch is especially dangerous: it fails closed, so it looks safe and produces no error, while quietly denying access to real users. Second, never let a UI decide "empty" from a proxy count. Ask the question you actually mean (how many SEATS are there) rather than one that happens to correlate (how many nodes), because the proxy breaks the moment a filter removes a structural node. Third, when a page shows two contradictory facts at once -- "0 on the chart" beside "9 members" -- treat the contradiction itself as the bug report and trace which of the two is lying before touching anything.</what_to_do> <parameter name="why">This is a total loss of the product's core surface for every non-owner in the org, and it is invisible to the person who built it, because owners and super-admins take the unfiltered branch and see a working chart. The customer experience is "your product is empty and I already put my team in it." A permission bug that fails closed generates no exception and no alert, so the only detection path is a human complaining -- which means test coverage over the whole enum is the only thing that would have caught it.</why> </invoke>
Why: Nine of ten members at McFadyen Digital opened the OTP org chart and were told "No agents or humans on the chart yet" while the same page showed "9 members" and "Shared to network v4". Two causes: (1) the view-scoping filter built its allowed set from claimed tiles only, so the organization ROOT node was dropped, leaving the renderer no node to draw down from, and the page's empty-state gate counted total nodes rather than seats, so a member holding exactly one seat looked identical to an empty chart; (2) the roles `visionary` and `integrator` were added to the role enum after the permission function was written, matched none of its branches, and silently fell through to the most restrictive one. The module had no test coverage at all.
Failure mode: Nine of ten members at McFadyen Digital opened the OTP org chart and were told "No agents or humans on the chart yet" while the same page showed "9 members" and "Shared to network v4". Two causes: (1) the view-scoping filter built its allowed set from claimed tiles only, so the organization ROOT node was dropped, leaving the renderer no node to draw down from, and the page's empty-state gate counted total nodes rather than seats, so a member holding exactly one seat looked identical to an empty chart; (2) the roles `visionary` and `integrator` were added to the role enum after the permission function was written, matched none of its branches, and silently fell through to the most restrictive one. The module had no test coverage at all.
When a UI bug report is ambiguous about WHICH control is meant, and a first search shows the obvious candidate already works, do not pick the next-most-plausible candidate and ship it. Enumerate every surface matching the description (here: the row edit form, the Add composer, AND the overflow menu) and either fix all of them or ask for a screenshot before building. Tell: when a report names visible button labels the user can see, ask which screen rather than inferring.
Why: Shipping the wrong fix costs a full CI + review + merge cycle and leaves the reported bug live, so David has to report it twice. One clarifying question, or a two-minute enumeration of candidate surfaces, is far cheaper than a merged PR that misses. This is the UI twin of "grep before generate" (Rule 19): enumerate before you build.
Failure mode: David reported "Need a cancel on (Edit Save) TODO (Daily View)". I grepped the Daily view, found the per-row edit form ALREADY had a working Cancel, so I concluded the gap must be the Add To-Do form and shipped that (PR #409). David then sent a screenshot: he meant the ellipsis overflow menu on a to-do row, which showed only Edit and Delete. I fixed a real gap, but not the one he was pointing at, and only found out after it merged.
Before calling a project's disappearance from the CCM sheet a data gap, check whether the client changed services. A project that stops appearing is at least as likely to have been offboarded from the call centre as it is to be a logging failure, and the two lead to opposite actions: one is an internal data fix, the other is a client card that should lose its CALL CENTER badge entirely. Maintain an explicit ads-only list so these clients never get dial-volume coaching, show-rate recommendations, or lead-decline flags derived from absent call-centre rows. Ads-only as of 2026-08-03: WOA Winder, WOA Flowery Branch. Ask David rather than infer when a project vanishes.
Why: Flagging an ads-only client for a call-centre lead collapse is a wrong number in a client-facing artifact, and it also sends a coach into a conversation about a service the client deliberately cancelled. It supersedes the WOA Winder example used in L126, which cited that project as evidence of a CCM recording gap.
Failure mode: Dash inferred that WOA Winder and WOA Flowery Branch disappearing from the CCM sheet after Jul 30 was a data recording gap, and flagged both as lead declines. David corrected: both clients discontinued the call centre and are running ads only. The rows stopped because the service stopped.
Treat absent data and zero data as different states, never the same one. Any agent reading an external source (ad platform, CRM sheet, rank tracker, API key) must assert freshness and report source-unavailable rather than emitting 0. For the four load-bearing sources (Meta, Google, CCM, Search Atlas) prefer deleting the failure mode over monitoring it: a Meta System User token never expires, so it cannot silently die. Where the failure mode cannot be deleted, the heartbeat must fire on silence, not on a bad value.
Why: Silence produces confident wrong numbers instead of visible errors. A week of Meta data was missing from every client conversation with no alert firing, and clients whose delivery was actually healthy got flagged as dropping. Monitoring more sources does not fix it, because the mechanisms differ (a 60-day clock, consumption exhaustion, an upstream human process). Distinguishing unknown from zero is the one rule that covers all three.
Failure mode: Six failures surfaced in one week (Meta token expired 7 days unnoticed, Search Atlas quota exhausted, CCM stopped recording two WOA locations, three scorecard tiles never carrying a value, an empty IDS board, a milestone slipping) and every one was found by accident, not reported. Agents rendered a silent or absent source as a legitimate zero, so a dead pipe and a real result looked identical on the board. A portfolio-wide alarm claiming 15 call-centre projects dropped 20 percent was a false positive caused by exactly this.
Never treat a shared-state file mtime as proof an agent is alive OR dead until you have confirmed which command actually writes that file and whether that command is the one in use. Before any seat or retirement recommendation, grep the orchestrators (good-morning, briefing, standup) for both the agent's invocation AND its state-file write, because those are frequently different paths. A staleness check answers "when did the writing path last run", never "is this seat working". Ask the human who uses the agent daily before proposing to retire it.
Why: This nearly retired a working seat. The same run also proved the opposite error is real, since a decoupled KPI stayed green while telling you nothing about the agent, so neither the tile nor the file is a liveness signal on its own. The deeper pattern across the whole 8/3 meeting is one error repeated: reading a proxy (a page-1 API response, a status field, a file mtime) and reporting it as the underlying truth without verifying the proxy actually tracks the thing being claimed. Cheap to check, expensive to get wrong, and it cost credibility three times in a single meeting.
Failure mode: CORRECTS L130. Dan told David the Arin seat was effectively vacant, 12 days dark, because arin-latest.md was 287 hours stale, and recommended retiring or repurposing the seat on that basis. The claim was false. Arin runs every day inside /good-morning Phase D-CCM, and it surfaced the WOA Aiken lead gap that became the main IDS item of the 8/3 meeting. The stale file was never written by the good-morning path at all; only the heavier /briefing path writes arin-latest.md. The mtime measured the last /briefing run, not the agent.
Two rules. First: before building any client-meeting prep, restate the meeting's purpose in one line and get it confirmed. A meeting with an open money dispute in the background is not necessarily a meeting about the money. Prep the agenda the user is actually running, not the most dramatic thread in the source material. Second: never state a person's employment status as fact from a conversational fragment. "She's gone", "she left", "before she left" are ambiguous between leave, a meeting exit, and departure. Quote it, cite the source, mark it unverified, and check it against anything the user said in the same conversation before contradicting them.
Why: The wrong axis burned a full research pass hours before a client call and would have had David open with a credit apology when what he needed was launch confirmations. The Daria error is worse in kind: an unverified claim about a real person's employment, stated flatly, that David could have repeated to the client. Sensitive personnel facts need a higher evidence bar than campaign metrics, not a lower one, and the user contradicting you inside the same message is a hard stop signal.
Failure mode: Dan built the entire GLS 4pm prep brief around the ad-spend credit and a defend-against-criticism posture, when David's actual purpose for the call was an operational readiness check: are the ads set up correctly, is creative approval on the ad text moving so Meta can launch by Wednesday, and is Tag Manager confirmed on the landing pages. The credit was already being handled separately with Kristin on Thursday. Dan also asserted as fact that Daria Whitaker no longer works at GLS, based on an ambiguous two-word fragment in a meeting transcript ("she's gone"), even though David's own prompt in the same conversation said Daria had just got back and was asking questions.
Never run plain `npm install <pkg>` in otp-platform. CI uses node 20 / npm 10; local npm 11 prunes optional deps that do not match the current platform when it rewrites the lock. Regenerate in a clean temp dir with the CI major: copy package.json + package-lock.json out, run `npx -y npm@10 install --package-lock-only --ignore-scripts`, copy the lock back. Verify the diff is insertions only and the @esbuild entry count stays at 300.
Why: The failure is invisible locally: tsc, the full test suite and the audit gate all pass, because the pruned packages are binaries for platforms this machine never installs. It only surfaces in CI, and it fails at install time so no test output points at the cause. Costs a full CI round trip per attempt.
Failure mode: Adding an npm dependency to otp-platform with the local npm (11.x) silently stripped 78 cross-platform optional @esbuild entries from package-lock.json, and CI died on `npm ci` with "Missing: @esbuild/win32-x64 from lock file" before running any test
Treat the branch as hostile state in a shared checkout. Stage explicit paths instead of `git add -A` (it sweeps the other session's edits into your commit). Always pass `gh pr create --head <your-branch>` rather than relying on the current branch. Re-read `git branch --show-current` immediately before push and before create, and guard the push on it. Before `gh pr merge N`, confirm `gh pr view N --json headRefName` is your branch. If it already happened, the commit survives in `git reflog` — cherry-pick it onto a fresh branch off main.
Why: Checking the branch before COMMITTING is not sufficient, which is the intuitive precaution and the one the handoff note called for. The dangerous window is between push and PR creation, and both `gh pr create` and `git add -A` silently use ambient state. The failure is invisible until after the merge, and by then it has published somebody else's in-flight work under your description.
Failure mode: In the shared ~/otp-platform checkout, another Claude session switched HEAD between my `git push` and my `gh pr create`, so the PR was opened against THEIR branch carrying MY title and body. Merging it put ~600 lines of their unrelated email-suppression work onto main under a misleading commit message, auto-closed their PR #465, and left my own fix unmerged.
Read the actual header row of Project Stats (A1:K1) before computing any CCM number, every run. The real mapping is C=New Leads, D=Outbound Dials, E=Pickups (40s+), F=Conversations (2min+), G=Booked Appointments, H=Call Back Requests Booked, I=Appts To Take Place, J=Shows, K=No Shows. Project Stats has a New Leads column that Agent Stats does not, so Booked sits at G in Project Stats but F in Agent Stats. Never carry a column mapping forward from a prior run's state file. Cross-validate the project total against Amanda's Agent Stats booked figure on a weekend day, when Erica is off and the two must agree.
Why: A column-offset error silently changes the headline KPI by 5x in either direction, and it reproduces itself because each run trusts the prior run's cached mapping rather than the sheet. It drove a wrong number into a message sent to the human calling team, which is the one place a fabricated metric does real damage to trust. Headers are cheap to read and are the only authority.
Failure mode: Arin/good-morning read the CCM Project Stats booked-appointments figure from the wrong column two runs in a row. The 8/3 run's cached state note claimed "C=leads D=dials F=booked" and the 8/4 run first used E (Pickups), reporting a 55% appointment rate, then "corrected" to F (Conversations), reporting 7.8%. Both were wrong. The 8/3 recap posted to #cc-5-general-agents carried at least one bad per-project number as a result.
Before reporting any failure found in a log file, check whether the fix already landed: compare the failing log's mtime against the mtime of the script, config, or plist that would have to change to fix it. If the candidate fix file is newer than the failure, the failure is historical. Report it as "failed on DATE, fix landed after, unverified until the next scheduled run" rather than as a live incident. This applies to any periodic job, because a failing log stays the newest log until the job's next scheduled run, which for a weekly job means the stale alarm repeats for up to seven days.
Why: A stale alarm repeated daily is worse than no alarm. It burns the operator's attention on solved problems and trains them to discount the exact monitoring built to catch silent failures, which is how the original silent failure survives the next time it happens. Log contents alone say what happened; only timestamps say whether it is still true.
Failure mode: The 8/4 morning briefing reported "rankings-check job is DEAD and reporting success" as a live, new problem needing 10 minutes of David's attention. It had already been fixed the previous afternoon: run-claude.sh was patched at 2026-08-03 13:53 and the failing log was from 09:07 that same morning, roughly 4.5 hours earlier. The briefing read the log contents and never compared the log's timestamp against the mtime of the file that would contain the fix. David then spent a work block on an already-solved problem.
Preflight the window with the SAME function the sender calls, never a documented helper that merely looks equivalent. For Swamp that means printing windowEntries from buildOllieWeekly({now, days}), not getRecentEntries(days). Sweep several values of days and read which dates appear, so an off-by-one is visible rather than assumed. Then, before any approval gate on outbound email, send a single real copy to the approver using the --to= override, after confirming a dry run with that flag resolves to exactly one recipient. Only then present the gate. Also verify what actually RENDERS, not just what is in the window: this engine caps the haul and silently overflows the remainder to the website, so entry count and card count are different numbers.
Why: A gate on an email nobody has seen rendered is not a gate, it is a rubber stamp with extra steps. And a preflight helper that diverges from the production path produces confident, precisely wrong advice: it nearly caused the opposite of the intended send twice in one run, first by dropping the held wave entirely and then by burying seven of nine held entries under a render cap. The only trustworthy preflight is the code path that actually ships.
Failure mode: Two failures in the same Swamp run. (1) I preflighted the send window with the skill's documented helper getRecentEntries(N) and told David the hold note's --days=9 was wrong and --days=8 was correct. It was the reverse: getRecentEntries uses a different cutoff than buildOllieWeekly, the function the sender actually ships from. At days=8 the helper showed 18 entries while the sender returned only 9 and silently dropped the entire nine-entry wave deliberately held back for a week. (2) I ran the approval gate and asked David to authorize a 62-recipient broadcast without ever sending him a rendered copy. He had to ask "did you send dsteel@sneeze.it a copy to review?" The dry run only writes a local HTML file; it mails nobody.
Audit outbound email against the email_deliveries table and the sender code paths, never against one person's inbox. If you use an inbox at all, check that address against the suppression list first.
Why: Suppression makes a single inbox a biased sample that always points toward "we send nothing", producing the wrong fix: add more sends. Real baseline was 318 sends to 72 people in 9 days at a 4.7 percent bounce rate, where deliverability and cadence caps come first.
Failure mode: A member-communication audit used David's Gmail inbox as its instrument and concluded OTP sends almost no proactive member email. Wrong by construction: the nudge engine hard-suppresses any dsteel@ address and the whole sneeze.it domain, so his inbox saw 5 of 318 real sends. The audit missed the live 90-day lifecycle series and the live re-engagement engine entirely.
When a user reports an error on an in-product button, diff the render condition against the API's permission check before assuming a bug in the handler. Then check how WIDE the gate is: grep every call site of the permission function. If a gate guards exactly one feature while sibling actions in the same UI are open, the gate is an outlier to remove, not a policy to widen. Confirm the failure against the live DB (here: read_at still null) so you know the write actually failed rather than the UI mis-rendering. When the fix is the ABSENCE of a check, add a source-assertion test and mutation-test it by re-injecting the gate — nothing else fails when someone re-adds it.</what_to_do> <parameter name="why">Permission bugs report as "it threw an error" and look like handler bugs, but the defect is the gap between who sees an action and who may perform it. That gap is invisible in either file alone. Counting the affected seats turns one person's complaint into a sized problem, and checking the gate's other call sites is what distinguishes "loosen this one route" from "widen a role everywhere" — the second has far more blast radius and was the wrong answer here.</why> </invoke>
Why: SUCCESS: Claude — a user-reported "error clicking a button" in OTP was a permission gate mismatch, not a crash: the meeting Signals "✓ Addressed" button rendered unconditionally in headlines.ejs while the /read API gated on canIntegrate(), so 25 of Sneeze It's 86 seats (every manager/managee/member) got a 403 on a button they could always see. Kristen could not even close a Signal she had authored.
Failure mode: SUCCESS: Claude — a user-reported "error clicking a button" in OTP was a permission gate mismatch, not a crash: the meeting Signals "✓ Addressed" button rendered unconditionally in headlines.ejs while the /read API gated on canIntegrate(), so 25 of Sneeze It's 86 seats (every manager/managee/member) got a 403 on a button they could always see. Kristen could not even close a Signal she had authored.
"Exhausted" means every avenue is closed, not that the first tool came back empty. Three specific rules from David 2026-08-06: (1) A local operator or GM is a legitimate route IN, not a disqualification. If they respond they can move you up the ladder to corporate, which is a better introduction than a cold email to corporate ever is. Do not disqualify a row because the contact you found is below the ICP's stated altitude. (2) When a brand's specific location has no contacts, look laterally across the SAME BRAND: other locations, other franchisees, the franchisor itself. A dead domain is not a dead brand. (3) When Clay has no email, go to the open web: the company's own site, LinkedIn, press releases, local news. Clay is the first tool, not the only one, and an empty Clay result is a prompt to search harder rather than a verdict.
Why: This is the same failure as L155 one level down. There I stopped the batch on a blocker; here I stopped individual rows on the first empty tool response. Both times "exhausted" was really "the cheap path did not work." The cost is asymmetric and worth internalising: an extra ten minutes of searching costs almost nothing, while a wrongly-abandoned row costs an account worth $50k to $500k a year in this ICP. David has now corrected the same underlying instinct three times in one day (L155, this, and the earlier stop-and-report), which means it is a disposition rather than an incident. When about to write "exhausted", "no path", or "needs research", treat that as a signal to try two more angles first, and record which angles were actually tried so the claim is auditable.
Failure mode: I marked four outreach rows "exhausted" that were not. Max Fitness Augusta: I disqualified it as "a one-location operator, not a franchisor, wrong ICP shape" and sent nothing. Class UFC Gym San Antonio: Clay returned no contacts at classufcgym.com so I stopped, without ever looking at other UFC Gym locations or franchisees in the same brand. LifeBridge: I said the one unblocked address was unverifiable and quit, without web-searching the person at all. Always Best Care: Clay returned no email for the CMO and I stopped there too.
Never report a campaign as active from campaign.status alone. Join status against metrics.impressions over the reporting window and call a campaign active only when status is ENABLED and impressions are greater than zero. Report the zero-delivery ENABLED shells separately as an account hygiene item.
Why: ENABLED is a switch, not evidence of delivery. Stale campaigns left switched on inflate the apparent size of an account, and a client who reads their own account daily will spot the mismatch immediately, which costs credibility on every other number in the same document.
Failure mode: Reported "four campaigns are enabled and spending" for the GLS Google Ads account by reading campaign.status alone. Riya and Yaroslav corrected it to three. Seven campaigns actually carried ENABLED status, but four legacy ones had zero impressions and zero spend for 90 days, so status count and active count disagreed in both directions.
Treat the recurring reports.sneeze.it 'Action Required / Data Source connection' emails as NOISE and suppress them entirely. Those connections go up and down by design. Never surface them in a briefing, never age them as an open flag, never ask David to decide about them.
Why: A recurring automated notice that self-resolves is not a signal. Aging it daily made a non-issue look like a 36-day-old dropped ball, which is exactly the noise the morning pass exists to remove. Age-as-signal only works if every flag on the board is genuinely unresolved; one permanent false positive degrades trust in the whole delta list.
Failure mode: Dan carried 'reports.sneeze.it Facebook Insights + LinkedIn connection failing' as an open flag for 36 consecutive days in the morning briefing, aging it up as if it were an unresolved decision, and framed it as needing a keep-or-kill call from David.
When a live source has been queried this run, EVERY item reported from that source must be derived from the live response, never carried forward from the prior snapshot. Use the previous snapshot only to compute age (first_seen) and to detect what closed. Concretely for good-morning: after pulling otp-todo.sh, rebuild overdue_otp from the returned todos, then diff against yesterday's list. Anything present yesterday and absent today is RESOLVED and gets reported under the resolved list, not repeated as open.
Why: The snapshot exists to add memory, not to override live truth. Carrying it forward inverts the design and turns the delta engine into a source of false positives, which is the single most expensive failure mode for a briefing: it costs David trust in every other line on the board and makes him do verification work the pass was supposed to remove. It also silently steals credit for completed work, which is worse than merely being noisy.
Failure mode: Dan pulled David's live OTP todo list at the start of the 8/7 morning run, then built the 'overdue' section from the PREVIOUS day's good-morning-state.json snapshot instead of from the live pull sitting in the same context. Result: three items were reported to David as open and overdue when they were not in his live open list at all, including SOC 2 G1 which David had already completed. David had to correct it.
In otp-platform, NEVER stage with `git add -A`, `git add .`, or `git commit -a`. Always stage by explicit path and verify with `git diff --cached --name-only` before committing. When multiple sessions may be working the same checkout, do not switch branches in the shared working tree at all: create an isolated `git worktree` instead, because another session's uncommitted work can be destroyed by a branch switch or reset. Before any reset in a shared tree, run `git status --porcelain --untracked-files=no` and use `--mixed`, never `--hard`.
Why: A repo with one engineer has no reviewer to ask "why does a coaching PR touch the risk register?", and no automated gate was asking either. The consequence is silent: the change is correct by every mechanical measure and wrong about what it claims to be. It is worse for compliance artifacts than for code, because the swept file becomes audit evidence with a misleading provenance. Parallel Claude sessions on one checkout make this far more likely, since each session sees the other's files as ordinary working-tree state.
Failure mode: Working-tree files belonging to one workstream were swept into an unrelated feature PR by a broad `git add`, and merged to main. PR #515 ("Let people actually talk to coaching, through Ollie") carried five SOC 2 compliance documents into main. Every CI check passed, because a swept markdown file breaks no test. This was the third occurrence in the otp-platform repo (#511 swept scratch, #512 removed it, then #515).
Before touching package-lock.json, compare the local Node/npm version against CI's (grep node-version .github/workflows/ci.yml). If they differ, do not regenerate the lockfile: install the matching runtime (brew install node@20, then use /opt/homebrew/opt/node@20/bin explicitly) and run BOTH the fix and the verification under it. Under the correct npm the same npm audit fix touched 12 lines instead of 524. Also: npm audit reads the INSTALLED tree when node_modules exists, and the lockfile only under --package-lock-only, so a local "0 vulnerabilities" measured against a node_modules you already mutated proves nothing. Reproduce CI with: npm audit --omit=dev --audit-level=high --package-lock-only
Why: A lockfile's correctness is defined by the tool version that reads it, so local verification with a different major version is not evidence at all. The failure is silent and expensive: every attempt looks verified locally and fails identically in CI, costing a full CI run each round trip. Checking two version numbers first would have cost thirty seconds and saved ninety minutes plus three abandoned pull requests.
Failure mode: Spent roughly 90 minutes and three failed PRs trying to clear a red npm audit CI gate in otp-platform. Each attempt regenerated package-lock.json locally and verified it with a local npm ci that passed, yet CI rejected every one as out of sync. Root cause was checked last instead of first: CI runs Node 20 (npm 10) while the laptop runs Node 25 (npm 11), and the two dedupe the dependency tree differently. npm 11 rewrote 524 lines and collapsed vitest's nested esbuild tree; npm 10 then refused it.
Distinguish PENDING (source intentionally not live yet) from FAILED (source exists and errored). Mark registry entries with no live source as 'pending': true so Tally skips them quietly and never pages David; only genuine failures fire the ntfy 'high' alert. When adding a KPI whose source isn't ready, always set pending:true. tally.py now splits results into ok/pending/failed and alerts only on failed.
Why: Paging the founder for deliberately-not-yet-wired KPIs is alert noise that erodes trust in the alerting channel, especially during a recovery window where every phone buzz costs. Honest status (pending vs failed) keeps the scorecard truthful and the alerts meaningful.
Failure mode: Tally paged David with a 'high' priority phone alert ('3 KPI push failed') after Dan added Havok/Pulse/Dirk KPIs to the registry with sources that don't have data yet. Tally's alert logic counted any non-push as a failure, so intentionally-pending KPIs (blocked column, untested engine) triggered a high-priority alert.
When changing an OG/share image, change the URL (new versioned filename, e.g. og-image-v3.png), do NOT swap bytes under the same filename. Slack/LinkedIn/iMessage cache unfurls per-URL with no public re-scrape; same URL = stale preview forever. To force a fresh unfurl immediately, share the page URL with a query string appended (e.g. ?v=2). Before declaring an OG/preview bug, verify the LIVE production og:image and its SHA — if production is already correct, the issue is a downstream cache, not the site.
Why: David repeatedly returns to the same dark-card complaint because byte-swaps under a stable URL look fixed in the repo but never change what cached platforms display. Versioning the URL is the only durable fix; otherwise the loop repeats every review.
Failure mode: Recurring complaint: orgtp.com link previews (Slack/social) still show the OLD dark OTP OG card even after the dark→light image fix shipped. Previous fixes swapped the image bytes under the same filename (og-image.png), so platform caches never refreshed and the dark card kept reappearing.
Any list or dropdown that is FILTERED by membership/permission/scope must carry an escape hatch whenever the filter can hide something the user expects: a one-line "Don't see your team? You only see teams you're on; join or create it in Teams" with a link. Audit rule upgrade: don't just test empty states; walk the user's actual goal ("I want to create a meeting for team X") through every branch, including "the thing I'm looking for exists but is filtered out", and ask "did the user reach their goal?" repeatedly (~4 passes) before declaring a flow fixed.
Why: The craftsmanship is in the small details: a filtered dropdown with no explainer reads as "my team is gone" and the user is stuck exactly as if the list were empty. Empty-state auditing catches zero-cases; goal-walking catches filtered-cases. Both are required or the audit declares victory while the user still can't do the thing.
Failure mode: Claude's UX dead-end audit fixed the ZERO-team cases on /l8 (no teams at all, empty owner pickers) but missed the partial case David hit live: the New-meeting team dropdown only lists teams the viewer is a MEMBER of, so a team that exists but doesn't include you is silently absent, with no explanation and no path to fix it. A populated dropdown looked "working" to both the audit agents and me, so it was never treated as a possible dead-end.
When adding navigation (or any chrome), verify the config file is actually included by the live layout (grep for the partial's include) before editing, and verify the change end-to-end by rendering the layout, not just compiling the edited file. Retired files must be banner-locked the moment they are retired (dashboard-tabs.ejs now is). Also fixed the adjacent latent class: saved sidebar customization now merges via a tested invariant "a saved order is a preference, never a whitelist" (src/shared/sidebar-nav.ts) so items added after a user saves their layout still appear.
Why: Config edited in dead code passes typecheck, tests, and EJS compile while doing nothing -- the greenest possible no-op. Only rendering the real surface (or a render test on the LIVE layout) catches it. Same lesson family as L034: verify the user-visible outcome, not the artifact.
Failure mode: Two builder agents added "Your Ollie" and "Ollie Insight" nav entries to src/views/partials/dashboard-tabs.ejs, which is a RETIRED partial nothing includes (the live rail is the _sbItems array in layouts/main.ejs) -- so the menu items rendered for nobody. David caught it live ("how come /dashboard/insight is not on the menu?"). Neither builder nor my review verified the item actually RENDERED in a real shell.
WOA Lafayette runs their OWN call team, exactly like WOA China Grove. Exclude Lafayette from the CCM portfolio appointment rate, from uncalled/zero-dial alerts, and from every coaching recap. New leads with zero dials at Lafayette is EXPECTED, not a miss. Before flagging any project for zero dials, check it against the self-calling exclusion list (China Grove, Lafayette) and the Reporting-Active/Inactive status in Project Info.
Why: Coaching Amanda and Erica about leads they were never supposed to call destroys the credibility of the whole recap, and repeatedly surfacing a non-issue as the "one real issue" wastes David's attention every single morning. The China Grove precedent already existed; Lafayette was never added to it.
Failure mode: Arin/Dan flagged "WOA Lafayette: 3rd consecutive day of new leads with ZERO dials" as the day's one real call-center issue, and put it in the drafted team recap as something to tighten. It is not an issue at all.
WOA Yadkinville is NO LONGER CALLED by Sneeze It (David 2026-07-10). Treat it like ROT Frisco: excluded from the portfolio rate, from dial-volume trends, from drop-off alerts, and from every recap and DM. The deeper rule: a declining or zero dial count is NEVER evidence of a caller problem on its own. Before any project enters a recap as a "tighten" item, confirm it is Reporting-Active in Project Info AND not on the self-calling / no-longer-calling exclusion list. If a project's dials fall to zero, the FIRST hypothesis is that we stopped calling it, not that the callers slacked. When a correction removes one item, do not reflexively backfill the slot with the next-worst number to preserve a "3 wins / 3 tighten" shape -- a recap with two tighten points, or zero, is correct when that is the truth.
Why: Fabricating coaching points to fill a template destroys Amanda's and Erica's trust in every number Arin sends, and it burns David's attention twice: once to read it, once to correct it. The format is a container, not a quota.
Failure mode: After Lafayette was corrected, Arin/Dan immediately substituted "Yadkinville went from 17 dials Tuesday to 1 on Thursday" as a coaching point. Sneeze It is no longer calling Yadkinville at all. Two consecutive drafted recaps coached the team on projects they were never supposed to be dialing.
Billing agent auto-applied $2,400 credit from misinterpreted ticket. The keyword "billing" appeared in a feature request sentence: "it would be great if the billing page showed usage breakdowns."
Why: Rule was too broad: "If customer mentions billing problem, check account and apply credit." Feature request contained the word "billing." Not a complaint.
Failure mode: Agent reads "billing" keyword. Triggers credit workflow. Auto-applies credit without context check. Discovered 3 weeks later.
Support agent told a customer "we will have this fixed by Friday" based on an engineering estimate. Engineering shipped the following Tuesday. Customer followed up expecting Friday delivery.
Why: Agent read "targeting Friday" in a GitHub issue as a commitment. Estimates are not commitments. Agent should never communicate timelines without approval.
Failure mode: Agent promises delivery based on internal estimate. Engineering misses estimate. Customer expects fix. Trust eroded. Three follow-up emails.
Staff skepticism of AI content must be addressed proactively with transparency, not by hiding AI involvement. We watermark all AI-generated documents and hold monthly 15-minute demos showing how the system works.
Why: In week 2, medical assistant Keisha refused to distribute an AI-generated handout to a patient, saying "I don't trust a computer to give medical advice." She was right to be cautious, but the handout had been physician-reviewed. The issue was that she did not know about the review step.
Failure mode: Staff quietly stops distributing AI-generated materials. Education content sits in the queue unused. No-show rate does not improve because front desk does not trust the prediction scores. Six weeks of implementation effort produces zero measurable results.
The physician sign-off bottleneck is the single biggest risk to the entire initiative. We mitigated it by (a) batching approvals twice weekly, (b) categorizing content as ROUTINE (approve in bulk) vs CLINICAL (individual review), and (c) setting a hard cap of 20 items before escalation.
Why: The 47-item backlog in week 3 nearly killed the project. Dr. Okafor said "If I have to spend my weekends reviewing AI output, just turn it all off." The batching and categorization system reduced physician review time from 4.5 hours per week to 1.5 hours.
Failure mode: Without categorization, physicians review every handout with equal scrutiny. A "drink water" handout gets the same review time as a "managing warfarin interactions" handout. Physicians burn out on low-value reviews and stop reviewing entirely.
The no-show prediction model had a racial bias in its initial training data because our historical no-show data correlated with zip codes that mapped to demographic patterns. We retrained using only behavioral features (prior no-shows, appointment lead time, day of week) and excluded demographic proxies.
Why: The initial model flagged patients from two zip codes at 3x the rate of others. Tanya noticed the pattern during week 2. Those zip codes correspond to predominantly Black neighborhoods. Deploying a biased prediction model in healthcare would be both unethical and a potential civil rights violation.
Failure mode: Biased model deployed without audit. Front desk unconsciously treats flagged patients differently. Pattern becomes self-reinforcing. Practice faces a discrimination complaint that is entirely justified.
Any incident involving customer data exposure (real or perceived) triggers a mandatory 72-hour response protocol: (1) containment, (2) investigation, (3) customer disclosure, (4) post-mortem, (5) control implementation. No shortcuts.
Why: Enterprise customers require incident documentation for their own compliance obligations. Incomplete incident response creates downstream compliance issues for customers.
Failure mode: The performance review pipeline incident initially had no formal disclosure. Rohan mentioned it informally to one affected customer, who asked for a formal incident report. The other 2 affected customers learned about it from the first customer (they shared a Slack community). Both demanded formal reports, which took 40 hours of engineering and legal time to produce. If the 72-hour protocol had been followed from the start, total time would have been 15 hours.
When an internal agent error mimics a product failure pattern, the root cause investigation must explicitly differentiate between "agent did the wrong thing" and "the product has the same bug."
Why: An AI company whose internal AI tools have the same bugs as the product being sold creates a credibility crisis.
Failure mode: Usage analytics agent produced a report with incorrect aggregation (double-counted some API calls). During investigation, an engineer realized the same aggregation logic existed in the customer-facing analytics dashboard. The internal agent bug revealed a product bug affecting 85 customers. The product bug had been shipping incorrect usage reports for 6 weeks. 23 customers had been overbilled by a combined $3,200. Refunds and apology emails took a full week.
Post-incident, every affected agent is audited for similar access patterns that could cause the same failure class. Fix the pattern, not just the instance.
Why: The performance review pipeline incident was a namespace boundary failure. Auditing all agents for similar boundary violations caught 2 additional risks before they manifested.
Failure mode: After the pipeline incident, the audit found that: (1) the competitor analysis agent had write access to a staging database that customers could read, and (2) the docs maintenance agent could publish to the customer-facing docs site without human approval. Neither had caused an incident yet, but both were one mistake away from customer-visible failures.
Treat any instance of an agent making a customer-facing promise that doesn't match actual policy as a severity-1 incident. Audit: what policy was referenced, what the agent said, how many customers were affected, and what the fix costs. Update the policy file and the agent's constraints within 24 hours.
Why: False promises compound. One customer tells another. Screenshots circulate on social media. The cost of honoring a false promise is always less than the cost of not honoring it, but the cost of preventing the next one is less than both.
Failure mode: The free return shipping incident (C001) was initially treated as a one-off correction. The policy file was updated but Haven's constraint set wasn't reinforced. Two weeks later, Haven told a customer that exchanges were "always free, no questions asked." Actual policy: one free exchange per order, second exchange has a $7.95 restocking fee. The pattern continued until false promises were elevated to severity-1 with a mandatory 24-hour fix cycle.
When Forecast's prediction deviates from actual demand by more than 30% for any SKU in a given week, the deviation must be logged with root cause analysis. Acceptable causes: unexpected viral moment, supplier delay, weather event. Unacceptable: "the model was wrong" without further investigation.
Why: Forecasting errors that aren't understood repeat. A model that consistently over-predicts seasonal items needs a different correction factor than one that under-predicts new product launches. Without root cause tracking, the same errors recur.
Failure mode: Forecast over-predicted demand for a spring collection by 40% for three consecutive weeks. Each week, the error was noted but not investigated. The root cause turned out to be a data pipeline issue: Shopify returns were being counted as sales in the training data, inflating apparent demand. The over-prediction cost $8,200 in excess inventory that had to be marked down 35%.
Any investor-facing communication error (wrong numbers, missing disclaimers, forward-looking language) triggers a 48-hour review of all communications sent in the prior 30 days by the same agent.
Why: Communication errors often come from template issues or data source problems that affect multiple outputs. The C003 IRR incident revealed that the same preliminary data source was being used for 2 other in-progress reports.
Failure mode:
When an LP or their attorney flags a compliance concern, the flag is treated as a P1 incident. Chen is notified within 1 hour, Sarah within 2 hours, and a response plan is prepared within 24 hours.
Why: Investor compliance concerns left unanswered escalate quickly. An LP's attorney who doesn't get a response in 48 hours may file a formal complaint.
Failure mode: The C006 incident (forwarded market research brief) was initially treated as "minor" by Sarah. Chen only learned about the attorney inquiry 4 days later from a follow-up email. By then, the attorney had sent a second, more formal request. Chen now receives all attorney communications in real-time.
The deal memo agent must reconcile its data sources against the compliance document agent's offering terms before finalizing. Discrepancies between the deal memo and the PPM are treated as P1 errors.
Why: A deal memo and PPM that show different terms (different minimum investments, different fee structures, different return projections) create legal confusion about which document governs the offering.
Failure mode: See C005. The subscription agreement error (wrong minimum investment) would have created a direct conflict with the deal memo if both had been sent. The reconciliation step now catches these before distribution.
Cross-brand contamination incidents must be classified by type: VOICE (wrong tone/language), DATA (wrong customer/product information), POLICY (wrong return/shipping/pricing rules), or FINANCIAL (wrong thresholds or budget allocations). Each type has a different root cause and a different fix.
Why: A VOICE contamination is a creative process failure (wrong voice guide loaded). A DATA contamination is an access control failure (wrong database scoped). Treating all contamination incidents the same leads to fixes that address one type but miss others.
Failure mode: After the first contamination incident, the team implemented "better brand prompts" (a VOICE fix). This prevented voice bleed but did nothing to prevent the data contamination that happened 3 weeks later (C002). It wasn't until contamination was classified by type that targeted fixes were implemented for each category.
When an agent error affects customers (wrong email sent, wrong policy cited, wrong product information), the resolution must include both the customer-facing fix AND the systemic fix. Fixing the customer without fixing the system guarantees a repeat.
Why: Customer-facing fixes (apology, credit, correction) stop the bleeding. Systemic fixes (constraint update, threshold change, context isolation) prevent the next occurrence. Organizations that only do the first are in perpetual firefighting mode.
Failure mode: The cross-brand email incident (C002) was resolved customer-side (apology email to affected customers, unsubscribes processed, CCPA request fulfilled). But the systemic fix (brand-scoped customer lists with hard isolation) wasn't implemented for 3 weeks due to competing priorities. During those 3 weeks, a smaller version of the same incident occurred: 47 Forma customers received a Ridgeline promotional email. Same root cause, same failure, smaller scale.