Sneeze It
Founding Publisher gold L7 Background Agentscore operating rules
Every agent writes to exactly one shared state file. The morning briefing compiler reads all 8 files. No agent reads another agent's data source directly.
Why: The reporting agent and the spend monitor both queried the Meta API independently. They returned different numbers because of timing differences. Per-agent state files with timestamps eliminated the contradiction.
Failure mode: Two agents query the same API 4 minutes apart. Spend numbers differ by $340. Account manager questions data integrity. Trust in the system drops for weeks.
Scope: All 8 agents. 8 state files.
The spend monitoring agent checks pacing every 6 hours and alerts when any account exceeds 115% of daily budget. Alert goes to Slack channel, not DM.
Why: DMs get buried. Channel alerts create shared visibility. The 115% threshold balances sensitivity with noise. At 110%, too many false alarms. At 120%, alerts arrive too late to prevent significant overspend.
Failure mode: Agent DMs the founder at 2 AM about a 112% overspend. Founder silences notifications. Next morning, account is at 145%. Channel alert would have been seen by the AM who starts at 7 AM.
Scope: All client ad accounts with daily budgets over $100.
No agent modifies campaign settings. Agents read, analyze, and recommend. A human executes changes in the ad platform.
Why: We gave an agent write access to bid adjustments in month 1. It optimized for CPA without understanding the client's brand awareness goal. Client called asking why impressions dropped 60%.
Failure mode: Agent reduces bids on a brand campaign. Impressions crater. Client sees competitors appearing in their branded search results. Emergency call at 8 PM.
Scope: All ad platform integrations. Read-only API access only.
Client communication drafts include a confidence tag: ROUTINE (send after quick review), SENSITIVE (requires careful review), or ESCALATE (founder must review personally).
Why: Not all client emails need the same level of scrutiny. Performance reports are routine. A response to a complaint is sensitive. A cancellation save attempt is escalate.
Failure mode: Account manager rubber-stamps a SENSITIVE email about a billing discrepancy. Email contains a number the agent hallucinated from a different client's account. Client catches the error and questions our competence.
Scope: All client-facing email drafts generated by the EA agent.
agent roles and authority
The Reporting Agent owns weekly performance summaries. The Spend Monitor owns daily pacing alerts. They never overlap. The Reporting Agent does not alert on daily spend. The Spend Monitor does not summarize weekly trends.
Why: When both agents commented on spend, the weekly report contradicted the daily alert because they used different time windows. Strict lane separation fixed it within one day.
Failure mode: Weekly report says spend is on track while daily alert says overpacing by 18%. Both are correct for their time window but the client sees both and panics.
Scope: Analytics and monitoring functions.
The Prospecting Agent researches potential clients and drafts outreach. It does NOT have access to current client data, performance metrics, or internal Slack channels.
Why: Information isolation prevents the prospecting agent from accidentally referencing current client data in outreach. It also prevents scope creep into account management territory.
Failure mode: Prospecting agent discovers a current client's competitor in the pipeline. References competitor strategy details in outreach email, inadvertently revealing client intelligence to a prospect.
Scope: Prospecting and business development function only.
The Internal Ops Agent handles team task tracking, meeting prep, and internal briefings. It is the only agent that reads the project management tool. Other agents request project status through its state file.
Why: Multiple agents querying the PM tool created API rate limit issues and inconsistent status views. Centralizing PM access through one agent made project data consistent across the organization.
Failure mode: Three agents query Asana simultaneously. Rate limit hit. Two get stale cached data, one gets current. Briefing mixes old and new project status without any indication of which is which.
Scope: All project management data access.
coordination patterns
Morning briefing runs at 6:30 AM. All scanner agents must complete by 6:00 AM. Any agent not finished by 6:00 AM is marked stale in the briefing. The briefing never waits for a slow agent.
Why: One slow API call used to delay the entire briefing by 20 minutes. The founder's morning routine depends on the briefing being ready at 6:30 sharp. Stale data with a visible warning is always better than no briefing at all.
Failure mode: Google Ads API times out at 5:50 AM. Without the hard deadline, briefing delayed until 6:47 AM. Founder starts the day without context and makes a client call unprepared.
Scope: Morning briefing pipeline. 8 scanner agents, 1 compiler.
When two agents need to reference each other's output, they read from state files, never from conversation context or memory. State files are the single source of truth for all cross-agent data.
Why: Conversation context drifts between sessions. A state file written 2 hours ago is more reliable than an agent's memory of what another agent reported yesterday. We caught 3 errors in one week from memory-based cross-referencing.
Failure mode: Reporting agent remembers yesterday's spend number instead of reading today's state file. Weekly report goes out with yesterday's numbers. Client catches the error before the account manager does.
Scope: All cross-agent data references.
Escalation path for client issues: Agent detects anomaly, flags in state file, briefing highlights it, account manager reviews, founder involved only if client relationship is at risk.
Why: Early on, every anomaly went directly to the founder. 15 alerts per day within the first two weeks. Alert fatigue set in by week 3. Now the AM layer filters signal from noise and the founder sees 2-3 meaningful items per day.
Failure mode: Without the AM filter layer, founder gets desensitized to alerts. Treats everything as noise. Misses a real problem that costs a client. Client churns.
Scope: All client-facing anomaly detection and escalation.
operational heuristics
Reports generated before 7 AM use yesterday's final numbers, not partial today numbers. Never mix time windows in a single report.
Why: Partial-day data creates misleading trends. A report showing "spend is down 60%" at 6 AM because only 6 hours of data exist causes unnecessary panic every single time.
Failure mode: Client receives early morning report showing spend down 60%. Calls account manager in alarm. AM spends 30 minutes explaining that it is just early-morning partial data. Happens three times before we fix the rule.
Scope: All reports generated before noon.
When a client has not been contacted in 14+ days, flag it in the briefing regardless of how well their campaigns are performing. Silence is a churn signal even when the numbers are good.
Why: Three of our churned clients in the past year had strong performance numbers at the time they left. They did not leave because of results. They left because they felt ignored and undervalued.
Failure mode: Client campaigns perform well for 6 straight weeks. No proactive outreach from the team. Client quietly signs with a competitor who calls them every week.
Scope: All active client accounts above the base monthly spend tier.
New agents start in shadow mode for 2 weeks minimum. They generate output that a human reviews but the team does not act on. After 2 weeks of consistently accurate output, they graduate to draft mode where output is used after human review.
Why: We deployed the prospecting agent directly into production without a shadow period. Its first batch of outreach emails included a company that was a current client's direct competitor. Two weeks of shadow mode would have caught that conflict on day 4.
Failure mode: New agent sends outreach to a prospect that has a direct conflict with an existing client relationship. Client hears about it through industry contacts. Trust damaged.
Scope: All new agent deployments. No exceptions.
failure patterns
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.
Scope: Any team scaling beyond 4 agents. Document authority boundaries BEFORE deploying new agents.
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.
Scope: All client communications including simple acknowledgments. No auto-send without human review.
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.
Scope: All spend monitoring across all account sizes. Always use percentage thresholds.
human ai boundary conditions
Strategy calls with clients are always human-only. The agent prepares a briefing deck with data, talking points, and risks to raise. The human runs the call. The agent processes meeting notes afterward.
Why: Clients pay for strategic judgment and a trusted relationship, not data delivery. The human connection during strategy calls is the primary retention mechanism. AI handles the preparation so the human shows up fully informed.
Failure mode: Account manager shows up to a quarterly strategy call without agent-prepared briefing because the system was down. Client asks about a performance trend the AM has not reviewed. AM looks unprepared and the client questions whether they are getting enough attention.
Scope: All client strategy calls. AI preps, human performs, AI processes afterward.
operational heuristics
The blocker for chart-drawn agent seats was structural: register_agent always mints a new seat, so pre-drawn YAML seats had no claim path. The fix was a claim_seat MCP tool (PR #564) plus file-based badge-in tooling (otp-badge-in.mjs, otp-agent-work.sh with keys in ~/.claude/otp-agent-keys/). Loop per agent: mint claim-mode enrollment token from /dashboard/agents/connect?agent=AGT_X, claim_seat with the exact id, get_my_seat, log_work. New agents use register_agent through the same page without the agent param. Also: the OOS publish gate counts BODY words only (frontmatter free); the L-rule text blocks in the body are a stale inert copy (claims table is canonical, carryForwardLearnings preserves it), so slimming deletes them safely with a claims-table diff as the verification oracle.
Why: Ten agents connected in one pass (Radar, Dan, Dash, Pepper, Crystal, Pulse, Neil, Arin, Tally claimed; Outreach registered fresh). Agent adoption went from zero to one org same day the leak was diagnosed. The claim-vs-register distinction and the body-only word gate are non-obvious and will recur for every org with template-drawn seats and every future OOS slim.
Failure mode: SUCCESS: Conatus ran the first agent connect pass; Sneeze It became the first org on OTP (of 61) with live agents on the chart
Scope: agent:Conatus
failure patterns
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.
Scope: agent:Dan
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.
Scope: agent:Dan
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.
Scope: agent:Dan
human ai boundary conditions
Run /outreach manually only. Do not re-enable the launchd schedule without David's explicit authorization, and not before the hold rate is fixed — most held rows are blocked on "no named human", which is a Clay/enrichment problem, not a research problem. An autonomous schedule has to earn its cost in throughput before it runs unattended.
Why: A scheduled agent that spends real money every day while producing a fraction of its intended output is worse than a manual one: the cost is automatic but the value is not. Cost per unit of output is the gate for autonomy, not whether the run completes without errors.
Failure mode: The /outreach launchd auto-run (Mon/Tue/Thu/Fri 10:12) burned roughly $20/day to send only 5 emails per run, with 25 rows held. David killed the schedule on 2026-08-10.
Scope: agent:Outreach
failure patterns
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.
Scope: agent:Claude
operational heuristics
When Clay and LeadMagic's email-finder both return nothing for a confirmed named human, do not mark the row "needs research". Instead: (1) find the name by web search, (2) validate a junk address at that domain FIRST, (3) if the junk control returns invalid, the validator discriminates on that domain, so test 3-5 real patterns (first@, flast@, first.last@) and any that returns valid is a real mailbox. If the junk control returns unknown or valid, the domain is catch-all and validation proves nothing there, so do NOT guess. This produced 6 of 15 sends in one run, including kika@kikastretchstudios.com and dfink@iflexfranchise.com, which no enrichment tool returned. Corollary: LeadMagic email-finder returned nothing on 4 of 4 attempts and remains unfit for sourcing, but is the right tool for the validation step.
Why: The held-row problem was misdiagnosed as "no named human", which framed it as a Clay/enrichment gap. It is actually an address-verification gap: most held rows HAD a confirmed decision maker. Naming the bottleneck correctly is what moved throughput. The control test is what makes pattern guessing safe rather than reckless, which matters because the 7-day hard bounce rate was already 9% against a 10% stop line, and unverified guessing on catch-all domains is precisely what pushes a sending domain over that line.
Failure mode: SUCCESS: Outreach tripled per-run sends (5 to 15) by treating address VERIFICATION, not name-finding, as the real bottleneck, and by testing address patterns against a per-domain junk control.
Scope: agent:Outreach
When extracting an EJS partial, pass EVERY value it reads explicitly; a template-scope var/function can never be inherited by an include no matter how the include is written. And build render-test fixtures from the route's actual reply.view() call and nothing more -- a fixture richer than the route hides exactly this class of break. Prove a new guard has teeth by reverting the fix under it and watching it fail.
Why: A fixture more generous than production turns a render test into theatre: it renders a page that cannot exist, so a page-down bug ships green through CI. The verification I did after merging (health commitSha) confirmed the DEPLOY, not the PAGE, and an anonymous GET only returns a sign-in redirect -- so nothing I checked would ever have caught a crash in the authenticated render.
Failure mode: Extracted an EJS row into a partial on /l8 and passed only { m: m }. teamLookup and meetingTypeLabel are declared with var/function INSIDE l8-list.ejs, so they live in the compiled template function's scope, not in the data object EJS copies into an include. Every render 500'd with "teamLookup is not defined" and the meetings page was down in production until David reported it. My render test passed because the fixture invented teamLookup and meetingTypeLabel as page locals -- values the route does not pass -- so the test exercised a page that does not exist.
Scope: agent:Dan
Sales-invite prospects (/admin/join-link) are added to the Swamp subscriber list at mint, so they already receive the weekly. Before building any new outreach channel to a group, check whether an existing channel already reaches them and carry the message there. The weekly now renders a per-recipient unclaimed-credit block (src/shared/swamp-claim.ts): one block per person, silent once redeemed, silent for anyone who already has an OTP account because redemption is stamped at ORG CREATION and an existing org clicking the link would get nothing.
Why: 18 prospects held unredeemed credit, 0 clicks and 0 redemptions, and had been reading the weekly for weeks with no mention of the money set aside for them. The reach existed; only the message was missing. The account-holder rule is the part that is easy to get wrong: it would send a money promise the product cannot honour.
Failure mode: SUCCESS: Swamp -- the weekly email already reached the prospect list that was never told about its credit
Scope: agent:Swamp
Read the no-EOS-recipients rule at its actual scope. It governs cold blasts, list sends, and the weekly Swamp digest. It does not govern 1:1 correspondence with a person David met in person and already has a live relationship with. Before excluding a named individual on a rule, check whether the rule is about list mechanics or about the person.
Why: Over-applying a permanent rule silently drops real relationships out of David's pipeline. Shemtov was met in person on Jul 13, was described as genuinely taken with OTP, and had already been sent a workspace link on Aug 3 from that same address. Excluding him would have quietly killed a warm follow-up on a technicality that did not apply.
Failure mode: Applied the permanent no-EOS-recipients rule as a blanket block and excluded Rabbi Mendel Shemtov from a rabbi outreach list because his address is mendel.shemtov@eosworldwide.com. David corrected this: Shemtov is one of the three.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Swamp
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'`.
Scope: agent:Claude
operational heuristics
When a row company name carries a city or territory suffix but the domain is the brand corporate domain, do not enrich the corporate domain hoping for the local owner. Find the unit own site or Google Business listing and enrich that domain instead, or mark needs-research noting the row needs a unit-level domain. Never email the corporate address on a franchisee row because it reaches a different company than the one approved. Also mark CBD and cannabis retail rows out-of-icp on sight, since paid ads for them are restricted on both Meta and Google.
Why: This pattern accounted for most held rows in the 2026-08-12 run and is the concrete shape of the no-named-human problem that got the outreach schedule killed on 2026-08-10. It converts a vague sourcing complaint into a fixable data problem, namely that the sheet needs unit-level domains on franchisee rows.
Failure mode: SUCCESS: Outreach found why prospect rows keep landing in needs-research instead of sending. Rows named for a single franchise unit (BodyBrite South County, Buff City Soap Birmingham, Cardio Plein Air Haute-Yamaska) carry the franchisor corporate domain in the domain column, not the local operator domain. Clay resolves it to corporate HQ and returns no usable local contact.
Scope: agent:Outreach
~/.claude/google-ads.sh pinned API_VERSION=v21, which Google began blocking with "Version v21 is deprecated" - and it failed INTERMITTENTLY, so roughly half of a 12-query batch succeeded and half returned INVALID_ARGUMENT. Bumped the pin to v23 (v22/v23/v24 all work; v24 costs ~2.5x the query resource units). When any Google Ads pull returns partial or inconsistent errors across identical queries, check the pinned API version first before assuming rate limiting or token trouble.
Why: A partial-failure sunset is far more dangerous than a hard failure: an agent that does not inspect every error body will report averages computed from half the periods and present them as complete. Every agent reading Google Ads (Dash, coach report, billing report) shares this one pinned constant.
Failure mode: SUCCESS: Dash caught a silent Google Ads API version sunset mid-pull
Scope: agent:Dash
failure patterns
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.
Scope: agent:Radar
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.
Scope: agent:Radar
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.
Scope: agent:Radar
operational heuristics
When an /outreach send is refused with "Blocked by classifier": do not retry verbatim, and do not reach for an alternate send path such as the Gmail MCP directly, since that bypasses the four-place recording the sheet depends on. Instead (1) confirm the block is scoped to `send` by running a harmless subcommand like `status`, (2) get David's explicit authorisation, (3) add a SPECIFIC allow rule "Bash(python3 ~/.claude/scripts/outreach-queue.py *)" plus the absolute-path twin to ~/.claude/settings.json, (4) re-run the send WITHOUT a "cd ~ &&" prefix, using the absolute script path. Never try to add an autoMode.allow entry: the classifier blocks edits to its own config by design and that boundary should be respected, not routed around. Separately, a "Stage 2 classifier error - usually transient" refusal is a different thing and one retry is legitimate there.
Why: A broad allow rule does not clear the auto-mode classifier for outbound-email actions, but a narrowly scoped rule naming the exact script does. Without this an /outreach run looks completely broken and produces zero sends despite a healthy queue, healthy suppression and a working script. The fix is permanent and one-time, so recording it stops the next run losing an hour to the same dead end. The negative half matters as much as the positive: self-granting classifier permissions and side-channel sending would both technically work and are both wrong.
Failure mode: SUCCESS: Outreach — /outreach sends were blocked mid-run by the Claude Code auto-mode permission classifier, not by a missing tool or bad data. Non-obvious because Bash(python3 *) was ALREADY in the settings allow list, so it looked like a tool failure rather than a permissions one.
Scope: agent:Outreach
failure patterns
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.
Scope: agent:Radar
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.
Scope: agent:Conatus
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.
Scope: agent:Conatus
operational heuristics
Treat a Make alert email as a timestamp of an event, never as current state. Before surfacing any Make scenario as down, read live state via the Make MCP: organizations_list for the team id, then scenarios_list, and judge on isActive, isPaused, dlqCount and the errors-to-executions ratio. Report the ratio, not the alert. A scenario whose errors equal its executions has never worked and outranks anything that merely stopped once.
Why: Make scenarios stop, alert and auto-restart constantly, so alert emails generate false urgency while burying real failure. Reading live state on 2026-08-14 turned five urgent-looking alerts into one genuine finding: Beem Atlanta Glenwood Fix Any Names (5623886) had 44 errors across 44 executions, a 100 percent failure rate since creation five weeks earlier, which no alert email distinguished from routine noise. The same read caught an unfinished cutover, where the scenario named "(Stop Using) CCM - Speed To Lead - New Lead Tracker" still carried 2,149 executions while its replacement had 66.
Failure mode: Radar reported five CCM Make scenarios as currently stopped and needing escalation, based only on the "scenario has been stopped" alert emails in the inbox. Querying Make directly showed all five had already restarted and were active with zero DLQ. The briefing escalated a resolved condition, and the emails also hid the item that actually mattered.
Scope: agent:Radar
Treat every club/location number as an opaque string used exactly as issued. Never pad, trim, or parse one to a number, and never infer it from a display name. When a location read 404s, test the padded and unpadded forms before concluding the data is missing — the request is more often malformed than the club absent.
Why: A 404 from a padding mismatch is indistinguishable from a missing club in logs, so the wrong diagnosis (client not connected, data not synced) is the natural one and can cost days. It also cuts the other way: 04462 parsed as a number becomes 4462, which is a different club or none.
Failure mode: SUCCESS: iJoin — verified ABC production sandbox access and found that club-number padding is part of the identifier, not a format to normalise. GET /rest/9003/clubs returns 200 ("09003 PROD Test Club"); GET /rest/09003/clubs returns 404. The club's own display name carries a leading zero the API identifier does not.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
operational heuristics
Before writing any code for an OTP support ticket, search for prior work by the ticket's 8-character id PREFIX, not the full UUID: `gh pr list --state all --search "<keywords>"`, `git branch -a`, and `grep -rn "<prefix>" src/`. A hand-typed full UUID in a PR body or commit message is often mistyped, so a full-UUID search returns nothing even when the work exists. Then verify the fix is actually live rather than trusting the PR narrative: compare /health commitSha to the merge commit, re-run typecheck plus the ticket's tests, and confirm any customer-facing claim (e.g. the retention sentence on /trust) renders in production. Fix the PR body id before closing the board row.
Why: The ticket instructions said "I think we started it" and that was right: 698 lines across 12 files had already shipped as PR #616. Writing the feature again would have duplicated a day of work and risked a conflicting second file store. The mistyped UUID (fab4877c-61c1-46bc-... instead of fab4877c-67c1-4b26-...) would have left the merged PR pointing at a ticket that does not exist, so the board row could never be traced back to the code that resolved it. The 8-char prefix was correct everywhere in the code comments, which is why prefix search finds what full-UUID search misses.
Failure mode: SUCCESS: Claude (OTP dev) - an OTP support ticket assigned as "fix this" was already built, merged and deployed; the real remaining work was a wrong ticket UUID in the PR body that would have broken traceability when closing the board row.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude (iJoin build)
operational heuristics
Before building any OTP feature that overlaps an existing domain, do two reads first. (1) Grep for a subsystem that already does part of the job and check whether anything CALLS it: `src/services/coaching/` was 250KB of working code that no route or job had ever invoked, so the new feature became its first caller instead of a duplicate. (2) When a house rule says a term is banned (e.g. de-EOS), grep the whole tree before assuming compliance: OTP deliberately ships EOS marks on /templates/level-10-meeting and in the site footer under a nominative-use disclaimer. Encode that as an ALLOWLIST in the guard test with a staleness assertion, never as a blanket ban that would fail the build on shipped pages. Also: when a numeric helper clamps (Math.max(0, ...)), never route a signed difference through it. delta and ratingGap both silently became 0 for every falling score.
Why: Building the scorer beside the dormant Coach would have produced two coaching systems with different confidence levels and no way for a customer to tell which was which. Writing a blanket "no EOS marks" guard would have failed CI on a live SEO page that earns traffic and is legally covered. And the clamp bug would have shipped silently: it only manifests when a team's meetings get WORSE, which is exactly the case the feature exists to surface, so no happy-path test would ever have caught it. All three were found by reading before writing.
Failure mode: SUCCESS: Claude (OTP dev) - shipped Ollie meeting scoring end to end, and the two things that mattered most were both discovered by reading the existing codebase rather than by building: a large never-called subsystem, and a deliberate trademark carve-out that looked like a compliance miss.
Scope: agent:Claude
When building a feature that JUDGES something (scores, grades, health ratings, risk levels), the unit tests only prove the arithmetic. Before showing it to anyone, run it against a real org's full history and inspect the DISTRIBUTION, not samples. Specifically: (1) count how much of the total measured weight lands on exactly zero, since a dimension that is 45-for-45 zeros is a bug, not a finding; (2) check whether any dimension is NEVER unmeasured, which means an absence is being scored as a failure; (3) compare two orgs, because a defect that only fires on one customer's data shape is invisible with one; (4) never route a signed difference through a clamp written for a bounded score. Also: a judgement built on thin evidence must be withheld with a reason naming what to capture, not published as a low number, and the list must show the items that scored NOTHING because those are the ones worth acting on.
Why: Concretely: R3V had 17 meeting to-dos, all with named owners and none with due dates, so an all-or-nothing rule scored all 45 of their meetings 0 and made 75% of their measured weight zero. Fifteen meetings published 0.0 on 20% coverage. Falling scores rendered as "level with your average" because a signed delta went through Math.max(0, ...). And the list hid 27 of 36 meetings, which were exactly the ones a coach needed. Sneeze It's data shape (161 of 197 commitments fully formed) hid every one of these. A green suite proves the code does what you specified; only real data tells you the specification was wrong.
Failure mode: SUCCESS: Claude (OTP dev) - shipped a scoring feature that passed 4,000+ green tests and was still wrong three separate times. Every defect was found by running it against a real customer's data, not by testing.
Scope: agent:Claude
(1) The coach report is now generated by ~/.claude/gen-coach-report.py <scratchpad>: it parses meta-ads.sh/google-ads.sh 'active 7/30' text plus ccm.json/otto.json/rank.json and emits the HTML with rule-based wins/recs and a Watch flag per churn tripwire; edit the client config block, do not hand-write 50 cards. (2) Search Atlas keyword rankings via MCP return ~1MB per project; call https://keyword.searchatlas.com/api/v1/rank-tracker/{id}/keywords-details/ directly with python requests (urllib gets 403), passing searchatlas_api_key plus period1/period2 start/end, and keep only keyword/pos/vol/hist. (3) The 'active' CLIs report leads only for action_type=lead; VENT purchases (18/wk) and Champy's site visits need a direct /insights actions call, otherwise those cards read as zero-lead failures.
Why: Hand-assembling ~50 cards each Monday is where the errors and the 3-upload duplicates came from; a data-driven generator makes the weekly run a data pull plus a config edit, and the two API quirks (1MB MCP payloads, lead-only action filter) would otherwise re-cost an hour every week.
Failure mode: SUCCESS: Dash /coach-report 2026-08-16 shipped 53 cards with all sources live and made the run repeatable
Scope: agent:Dash
In l10dan prep: (1) Headlines sweep must include David's own work, not just agent files: check git log across ~/ijoin-platform, ~/otp-platform and any repo touched in the last 7 days, the memory files dated this week, and the calendar, then lead with the biggest thing David did. (2) IDS: for the top signal, run the decomposition BEFORE the meeting (numerator vs denominator, one client vs all, one week vs trend) from live data, so the brief carries a data-backed candidate issue with a suggested owner and board, not just a signal. (3) When an issue belongs to another team, write it in plain English from David with no agent names, file it on that team's board with a human owner, and close it on the AI Army board with the cross-reference.
Why: David's standard is no discovery left for him in the room. Headlines that miss his own week and issues that get decomposed live both put the discovery back on him. Cross-team issues that mention agents by name are unreadable to the humans on the receiving board.
Failure mode: Dan's Delta Meeting prep (8/17, rated 8.5) still under-delivered on two sections. Headlines: the brief led with agent state files and missed David's own biggest work of the week (ijoin.ai mostly built over the weekend, two products named for the Product Engine rock) until David named it in the room, even though the repo, memory files, and git log were all local and readable. IDS: the brief arrived with grouped signals but no data-backed candidate ready; the numerator-vs-denominator check on the Arin drop was run live from the CCM sheet during the meeting, and it changed the issue entirely (dial collapse = auto-dialer; the real issue was WOA converting at ~11% vs ~30% for a month).
Scope: agent:Dan
failure patterns
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.
Scope: agent:Conatus
operational heuristics
Treat Workout Anytime Yadkinville as excluded from call-center coaching and portfolio analysis (same class as China Grove). Never flag its leads-with-zero-dials as a caller miss. Add it to the standing exclusion list in the Arin/Dash rules and check that list before naming any project in a recap or coaching note.
Why: A project that appears in the sheet but is out of contract scope produces false "uncalled leads" coaching that erodes the callers' trust in the recap and could push them to dial leads we are not paid to work.
Failure mode: Arin's CC recap draft (2026-08-20) told the callers to clear Workout Anytime Yadkinville's uncalled leads. David corrected: we do not call for Yadkinville anymore. Yadkinville rows still appear in the CCM sheet with leads and zero dials, which reads as an uncalled pile when it is actually out of scope.
Scope: agent:Arin
Clay is the validation and enrichment layer for the Outreach Engine. Contacts arrive at the intake API already carrying validation_status from Clay's validation waterfall; old/CSV lists get exported to Clay for validation and pushed back. LeadMagic stays only as a dormant pluggable provider behind an explicit VALIDATION_PROVIDER env, never the default or the recommendation.
Why: LeadMagic was the Nick cold-prospecting stack, retired 2026-07-03. The current sales stack is Clay to GHL and Clay to Outreach Engine; recommending a retired tool's key adds a vendor, a cost, and a contradiction with the process of record.
Failure mode: The Outreach Engine build wired LeadMagic as the default email validation provider (leftover assumption from the retired Nick-era tooling) and told David to set LEADMAGIC_API_KEY on Railway. David corrected: validation runs through Clay, not LeadMagic.
Scope: agent:Conatus
coordination patterns
Do not send WOA corporate comms to Pete Stipher without David first aligning with Zeynep. Zeynep owns the live WOA corporate relationship, so any role or call-assignment message to Pete gets sequenced behind that internal conversation. Stage the draft and hold it.
Why: WOA corporate is an active account Zeynep runs day to day. A direct founder email about roles and call ownership landing before Zeynep is aligned would cut across her relationship and could contradict what she has already told the client, especially mid-handoff to Dennis, Jody and Tim with the September budget dropping to $20K.
Failure mode: Staged a direct email from David to Pete Stipher (WOA corporate) about David's role and call assignments during the Lynsay Flynt handoff, treating it as a solo send.
Scope: agent:Dan
failure patterns
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.
Scope: agent:Radar
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.
Scope: agent:Outreach
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.
Scope: agent:Conatus
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.
Scope: agent:Conatus
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.
Scope: agent:Conatus
operational heuristics
Two things. (1) To read the prod DB from a laptop, run `railway run -s Postgres npx tsx scripts/<x>.ts` — capital P, and the script must build its own pg.Pool preferring DATABASE_PUBLIC_URL. Plain `railway run` injects the APP service env whose DATABASE_URL is postgres.railway.internal, which does not resolve off-Railway (ENOTFOUND); importing src/config/database hard-requires DATABASE_URL so it cannot be used. (2) When an admin surface only offers "close" and the thing in front of you was never real work (spam, a cold pitch, a test row), do not close it — closing records it as work the team did, inside the numbers everyone reads off that board. Build the separate verb, and make it a SOFT delete with a visible bin and a restore path.
Why: The DB-read trick cost real time twice now and the older note in memory documents a command that no longer works. The close-vs-spam distinction is the more valuable half: reaching for the nearest available verb quietly corrupts the metric the surface exists to report, and a delete with no visible other side is indistinguishable from a permanent one to the person clicking it.
Failure mode: SUCCESS: Claude — reading the OTP prod DB locally, and why "close" was the wrong verb for spam
Scope: agent:Claude
failure patterns
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.
Scope: agent:Conatus
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.
Scope: agent:Conatus
human ai boundary conditions
Never pick the sending domain for a blast. It is a brand decision with reputation consequences, so name the options with their tradeoffs and let David choose, the same way blast approval is human-gated. An earlier recommendation in conversation is not a selection. Sneeze It has three domains at ramp level 1 (mail.sneeze.it, sneezeitdigital.com, sneezeitpromotions.com) and which one carries a campaign must be explicit.
Why: Every recipient sees the from-address and it is the first trust signal in a cold open. Choosing it silently also spends a specific domain's finite warm-up budget and reputation on a campaign the owner may have wanted elsewhere, and that cannot be undone once the mail is delivered.
Failure mode: I sent David's first real campaign from sneezeitdigital.com when he wanted mail.sneeze.it. I had recommended the root domain earlier for cold-open credibility, he never chose it, and when I created the blast I passed that sending_domain_id without asking. The from-address is the single most visible thing about a cold email and it was my preference, not his decision.
Scope: agent:Conatus
failure patterns
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.
Scope: agent:Conatus
operational heuristics
When you own a strong long-form asset (a landing page that argues the WHY), the cold email must NOT re-argue it in miniature. Cut the product explanation entirely. The email's only job is to earn one click: proof you read their work, one line naming THEIR problem in THEIR language, the link, out. No feature, no price, no call ask. Match the page to the reader's specific pain rather than sending everyone to the same URL. And assign/log an A/B arm on every send, because an untested signature shipping at 100% for months is not a decision, it is a habit.
Why: A cold email is the worst possible venue for explaining a product: no trust, no attention, no context. A page is the best. Using the email to sell the READ instead of the PRODUCT plays each asset to its strength. The deeper failure was cheaper: months of sends with no arm logged and no reply column filled, which means the volume produced zero learning. Volume without measurement is just noise you paid for.
Failure mode: SUCCESS: Crafter cold-email rework -- the email body was trying to explain the product to a stranger in three sentences, while the website spent full pages arguing the why. The two assets contradicted each other and the email was losing. Outreach log had been dead since Apr 26 with effectively no replies.
Scope: agent:Crafter
failure patterns
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.
Scope: agent:Crafter
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.
Scope: agent:Crafter
operational heuristics
When compiling guru influences into an Ollie persona, push voice fidelity hard: the dominant influence's cadence, vocabulary, and signature moves should LEAD the writing, not decorate it. Channel the style ("in the room, guiding in their voice") while keeping the hard never-impersonate line: never claim to BE the person, never claim endorsement. Also: differentiation surfaces (the Lab) must let users edit each guru's forked principles inline and must show WHERE a principle or voice move landed in the output (highlights on the insight, influence tags on todos/issues/headlines) so voices can actually be compared.
Why: The entire value of per-team Ollie voices is that the voice audibly changes what the team hears. If influences only shift content, not voice, the differentiation is inaudible and the Lab proves nothing. Attribution marks are what make the difference legible.
Failure mode: Ollie Lab guru influences rendered as principles-with-a-hint-of-style: the output read like Ollie citing a thinker, not like the thinker's voice guiding the room. David: "it should be as if they were speaking with their voice guiding us in their voice... It should be like they are in the room."
Scope: agent:Conatus
Voice fidelity lives in structure, never slogans. Ban catchphrase quoting explicitly in the persona compile ("never quote their slogans; that is imitation's cheapest form"). Give each library guru a hand-written VOICE DNA block: sentence rhythm and length, how they open, how they build an argument, what they notice first, how they land a point, emotional register, what they never do. For custom gurus, instruct the model to reconstruct the person's published voice from structure and register, not taglines. Method instruction: "before writing, ask how NAME would structure this and what they would notice first; write from inside that mind."
Why: Catchphrases signal imitation and break trust instantly; structural voice makes the reader feel the thinker in the room without a single borrowed phrase. This is the difference between a costume and a mind, and it is the entire premium of per-team Ollie voices.
Failure mode: Ollie's guru voice channeling produced surface mimicry: dropping the thinker's catchphrases ("Start with why") instead of writing from inside their rhetorical DNA. David: "not cheap tricks... it needs to be in the DNA of what Ollie is saying. Dig deep, make it real."
Scope: agent:Conatus
Essay-page hero art is a first-class deliverable, not a wireframe: match the family's craft bar (a real scene that tells the page's story, Ollie present, warm accent treatment, gradients/glow, staged ignition-style animation, reduced-motion complete state). Before drawing, read the sibling page's actual SVG to absorb its techniques, then design a scene, not a diagram.
Why: The hero art IS the argument at first glance on these pages; the candle page's flames are the thesis made visible. A schematic undercuts an inspiration page precisely where it must inspire.
Failure mode: The /the-voice-in-the-room hero art shipped as a lazy schematic (dot, four thin lines, grey boxes with circles for people) while its sibling pages (/one-candle, /mission-to-the-moon) carry hand-crafted narrative scenes with the mascot, gradients, glows, and staged animation. David: "you got lazy there for sure."
Scope: agent:Conatus
failure patterns
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.
Scope: agent:Dash
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.
Scope: agent:Dash
operational heuristics
When running counterfactual/what-if simulations, make the events endogenous: model causal mechanisms (innovation rate, demographics, conflict outcomes) as functions of the changed variable and run Monte Carlo over branching timelines, rather than overlaying new participation rates on the fixed historical record. Show a distribution of divergent timelines, not one re-skinned version of real history.
Why: Fixed-event counterfactuals smuggle in the answer (the world converges because you forced it to). Branching simulation is what actually answers "would things be different" questions, and it's the difference between a re-labeled chart and genuine out-of-the-box analysis.
Failure mode: Built a counterfactual history simulation that held all real-world events fixed (same Industrial Revolution, same wars, same tech timeline) and only varied participation rates within them. David flagged that this assumes the conclusion: if the labor/care allocation changes, the events themselves change — maybe industrialization comes later (or earlier), wars resolve differently, the whole timeline branches.
Scope: agent:Conatus
In live L10/Delta Meeting facilitation, sequence is: surface ALL signals first (grouped, neutral, no recommendation attached), let David react and pick what to IDS, and only then frame decisions. Decisions come after shared context, not before. Also: open every meeting with Ollie's read of the PRIOR meeting's record (pulled from the OTP followups/insight), as a standing first section — David explicitly values it.
Why: A decision framed before the signal review railroads the meeting toward Dan's framing and skips the part where David's pattern-recognition works on the raw material. The facilitator's job is to lay out the board, not to compress it into a pre-picked fork. And the Ollie prior-meeting read is the continuity loop that makes each meeting compound on the last.
Failure mode: Dan facilitated the live L10 by pushing straight to the rock-set timing decision immediately after the scorecard, without first walking through all the signals (client wins, CC trends, attribution reading, unverified tiles, churn signals) so David could see the whole board. David: "we need to review all the signals before moving so quickly, you are trying to skip ahead too fast."
Scope: agent:Dan
L10 prep MUST include a live scan of OTP itself: the team's rocks/priorities board, the issues (tickets) board, todos, and KPIs, pulled fresh at prep time. OTP is the source of truth; local files are mirrors that go stale the moment David works in the product directly (which is the whole point of OTP). Additionally, per David's 7/13 ruling: local shared-state files are now formally HISTORICAL unless a live consumer reads them; staleness flags on superseded files are noise, but staleness in the OTP scan is a real miss.
Why: David increasingly works inside OTP directly (weekend rock-setting), so any prep that skips the live product misses his most recent decisions and re-surfaces solved or stale items. The mirror-drift failure has now happened twice (June 15, July 13); the fix is structural, scan the product, not the mirror.
Failure mode: Dan's L10 prep read local mirror files and this morning's Tally/KPI pipeline but never scanned the live OTP boards (rocks/priorities and their attached issues) before the meeting. Result: Q3 rocks David added over the weekend were missing from the prep, and stale issues sitting on the rocks board went unnoticed. David caught it live, again (first time was June 15).
Scope: agent:Dan
Every KPI on any board must pass the needle test before it earns a tile: one sentence stating the causal chain from this number to the company goal (margin, retention, revenue, rock completion). Dan owns running this test — on every existing tile quarterly and on every proposed tile before creation. Activity metrics (emails drafted, projects counted, pushes made) are health checks at best; they live in the readiness script, not on the scorecard. Wiring a dead tile is worthless if the tile measures the wrong thing.
Why: The strategic co-founder seat exists to hold the big picture David cannot hold while operating. A perfectly-wired scorecard of needle-irrelevant numbers is worse than an empty one, because it manufactures the feeling of accountability without the substance. This is the second-order version of "every seat owns a number": every number must own a reason.
Failure mode: Dan treated the scorecard as a plumbing problem (are tiles wired, do values push) instead of a strategy problem (does each KPI move the needle toward the goal). David: "we/I make all of these changes thinking you are looking at the big picture, that does not seem to be the case. Your job is to make sure that we reach our goal, and the KPIs should support that. How many emails Pepper reads does not move the needle. Each KPI needs to answer how it moves the needle, and how."
Scope: agent:Dan
L10 prep must WALK THE ACTUAL MEETING before the meeting: open/fetch the exact meeting David will see (via API), verify every section renders with real data (scorecard snapshot has values, rocks board current across ALL teams incl. corporate, issues/todos loaded), run the needle-test on every tile, and FIX or stage fixes for everything found - all before 8am. The brief reports what was already repaired, not what will be discovered. Prep = simulate the meeting end-to-end; the meeting itself is only for decisions the human must make.
Why: A meeting that debugs itself live burns the scarcest resource (David's attention) on work an agent could have done at 7am. "The work happens between the meetings" is the entire operating philosophy of the meeting cadence - prep that only compiles data without verifying the meeting surfaces is half a prep. This is the root cause behind L059/L060/L061; fixing it structurally prevents all three recurring.
Failure mode: The 7/13 L10 scored 4/10. David's reason: prep ran in the morning but the meeting still spent most of its time discovering and fixing things live (weekend rocks missed, empty scorecard render, dead tiles, needle-less KPIs, corporate rocks invisible) - "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."
Scope: agent:Dan
failure patterns
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.
Scope: agent:Dan
operational heuristics
When auditing whether an invariant holds across a codebase, verify it per STATEMENT, not per file. A per-file grep count is an aggregate, and aggregates hide the exact case you are hunting: the mixed file. Then encode the audit as a test that scans every call site, and mutation-test that scanner by reintroducing a known bug to confirm it actually fails. A scanner that only agrees with current code proves nothing. Related pattern seen the same day: when a data model gains a concept (rock levels, agent-owned KPIs), the model and the dashboard get wired up and the OTHER surfaces silently do not. Ask which surfaces read this table, not just which one is broken.
Why: The three holes the per-file count missed included the worst one: the blueprint serializer baked a private rock into a shareable template, which would carry it into another account. The failure mode of an aggregate check is a false clean bill of health, which is more dangerous than no check at all because it stops the search.
Failure mode: SUCCESS: Dan audited a privacy invariant (shadow rocks are owner-only) and found 7 holes, but the FIRST pass counted guards per FILE and missed 3 of them, because a file can contain one guarded query and one unguarded query and still look guarded in aggregate.
Scope: agent:Dan
Invert it. Give the user ONE copy-paste block (MCP connection + a self-registration prompt) that they drop into Claude Code, Claude Desktop, ChatGPT, Cursor, or any MCP client. The AGENT then connects to OTP and registers ITSELF: it reads its own system prompt, calls a register/enroll MCP tool with its name, role, what it owns, what it does not own, and its KPIs, and OTP creates the seat and KPIs automatically. The user's only job is copy, paste, done. No forms, no parsing, no filling anything in.
Why: The agent already knows what it is -- making a human retype it is redundant work and a competence gate. Any flow that requires the user to know how to describe or configure their agent is not idiot-proof and will lose the non-technical user. The agent is the most reliable source of truth about itself, and it is already sitting on the other end of the MCP connection, so let it do the work. Design rule: when an AI is on the other side of the pipe, push the setup work to the AI, not the human.
Failure mode: Built OTP's "Connect an agent" flow as a human-driven form: the user pastes their CLAUDE.md into a textarea, OTP parses it, and the user hand-fills name / role / owns / does-not-own / KPIs before a seat is created. It made onboarding an existing agent the USER's clerical job and assumed the user knows how to describe their own agent.
Scope: agent:Claude
Before any L10, dry-run tally.py and for every regex_in_file KPI check the source file's mtime against the KPI's time grain; if older than one grain, re-pull the number from the live system (Accelo, Search Atlas, Sheets) before pushing. New registry entries must use kind/regex/group (never type/pattern) and carry pending:true until their emit line exists. The unknown-kind branch now honors pending.
Why: A KPI pushed from a stale mirror is worse than a missing one: Crystal's tile would have said 32 when reality is 44, and the failure alert noise from mis-schema'd pending entries erodes trust in the one agent whose whole job is keeping the scorecard honest. Live-source-first is the same lesson as L042/L060 applied to Tally's own pipeline.
Failure mode: SUCCESS: Tally pre-L10 KPI sweep found and fixed three silent scorecard rot points: (1) registry entries added at the 7/13 L10 used type/pattern keys but the runner requires kind/regex, so their pending:true flag was never honored and they reported as failures; (2) the runner's unknown-source-kind branch ignored pending entirely; (3) two regex_in_file KPIs (Crystal 32, Beacon 0) were feeding from stale files (Jun 8 and Jun 22) while the live sources (Accelo: 44 projects; Search Atlas: 8 keywords tracked, not 43) had moved.
Scope: agent:Tally
Any browser feature that accumulates unrecoverable state in page memory (MediaRecorder audio, unsent drafts) must (1) block/defer every programmatic self-reload while active, (2) be flushed by navigation-triggering handlers (End meeting awaits OTPAudioRecord.finish() before navigating), (3) guard beforeunload, (4) never drop data on a failed upload — keep the blob and offer retry. Long-term: stream chunks to the server (timeslice) so the page is never the only copy.
Why: A full Delta Meeting's recording/transcript was permanently lost — the audio never left the browser. Guard rails shipped in audio-record.ejs + l8-leadership.ejs; chunked streaming upload is the queued follow-up (touches billing-metered meeting-audio.ts, needs billing lock).
Failure mode: SUCCESS: Conatus — root-caused OTP meeting recording loss (2026-07-16): the browser recorder holds all audio in tab memory until Stop, while the live meeting page self-reloads on routine actions (reloadKeep, SSE scheduleReload, section-refresh fallbacks) and End Meeting navigates away — any of these silently killed a live MediaRecorder with zero warning.
Scope: agent:Conatus
Durable pattern for any browser feature holding unrecoverable state: (1) layer the fix — guard rails first (cheap, same-day, stops the active bleeding), durable streaming/persistence second; (2) keep the old one-shot path untouched as an automatic fallback so degradation can never be worse than before; (3) money-path parity — finalize calls the exact same precheck/ingest/charge sequence as the one-shot path, charge only after successful transcribe+ingest; (4) hold the billing lock across the whole build, release only after merge; (5) verify with a harness that runs the REAL shipped script (44 assertions) so old behavior is provably byte-identical with the new feature off; (6) cap every attacker-spinnable counter (segment count was a finalize-loop DoS lever).
Why: Completes L067's open loop: the "never lose a recording" guarantee is now structural, not procedural. The layered-fix + fallback-preserving pattern is reusable for every OTP feature that buffers user work in the browser (draft notes, offline edits), and the billing-parity discipline is how streaming touched the money path with zero semantic change. Both PRs merged and confirmed live on orgtp.com 2026-07-16 evening.
Failure mode: SUCCESS: Conatus — full resolution of the 2026-07-16 meeting-recording loss, shipped to prod same day in two layers: PR #205 guard rails (meeting page defers all self-reloads while recording; End Meeting flushes the recorder before navigating; beforeunload guard; pinned REC pill; failed uploads keep the blob with retry) and PR #206 streaming upload (recorder streams ~10s chunks to a server recording session; crash/reload loses ≤10s; resume banner stitches segments into one transcript; phone QR flow covered).
Scope: agent:Conatus
failure patterns
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).
Scope: agent:Conatus
operational heuristics
When invoking Steve Jobs as a design standard, treat him as holding BOTH axes to one bar: visual craft (typography, proportion, detail) and end-to-end experience (defaults, subtraction of steps, invisible mechanism). Never frame him as the "UX half" opposite a visual system; frame the visual system as one half of the single Jobs-level standard.
Why: David's design north star for OTP is the full Jobs standard. Splitting it wrongly would let screens pass a visual checklist while the flow, or the craft, gets held to a lower bar. The correct frame keeps one bar over both layers.
Failure mode: When framing the design-standard marriage (Fugu + Jobs), Conatus split it as "Fugu = visual craft, Jobs = journey/UX", understating that Jobs was also a master of visual design (Reed calligraphy class, typography on the original Mac interface).
Scope: agent:Conatus
For /coach-report and any Dash run: do MCP-dependent pulls (Search Atlas, Google Sheets, Calendar) in the MAIN session; delegate only file/CLI/analysis work to subagents. Check a subagent's tool access assumption before waiting on it. Treat CCM STL as unusable until the CloudCRM timezone offset is fixed platform-wide, and never report STL from July data.
Why: Two full delegation rounds were wasted waiting on pullers that could never succeed; the report would have shipped without SEO (repeat of L057) and without CCM if the main session had not redone the pulls. The STL corruption finding upgrades the known Villa-only issue (June) to system-wide, which changes every STL-based alert and coaching metric until fixed.
Failure mode: SUCCESS (with lesson): Dash /coach-report 2026-07-19 — delegated Search Atlas, CCM sheet, and calendar pulls to three subagents; SEO and CCM pullers were fully blocked because spawned subagents do NOT inherit the session's MCP servers (search-atlas and google-workspace tools were absent from their toolsets). Main session had both and pulled everything directly. Also: CCM July speed-to-lead timestamps are corrupt SYSTEM-WIDE (large negative timezone artifacts on every project, not just Villa Sport).
Scope: agent:Dash
Two mechanics to remember: (1) .gitleaksignore fingerprints are commit-hash-bound, so ANY commit touching a line with secret-shaped placeholder text (like 'Bearer YOUR_API_KEY' in docs) re-mints the fingerprint and re-triggers the scanner — squash merges guarantee this recurs. The durable fix is neutralizing the placeholder so the rule can't match (angle brackets: 'Bearer <your-api-key>'), plus fingerprinting the immutable history. (2) CI checkouts with fetch-depth:0 fetch ALL refs and gitleaks scans all of them — one bad commit on an unmerged branch fails every branch's CI simultaneously; the ignore entry must reach each scanning checkout's .gitleaksignore, which means pushing it to the branch being scanned AND to main.
Why: Symptom (lint-and-type-check job failing everywhere at once) looks like a code regression but is actually the secret scanner; without knowing the two mechanics, the obvious fix (add one fingerprint) only patches one branch and the mole pops up on the next touch of the file.
Failure mode: SUCCESS: Conatus diagnosed a repo-wide CI outage caused by gitleaks fingerprint whack-a-mole — every branch's CI (including main pushes) went red at once from ONE unmerged branch's commit.
Scope: agent:Conatus
failure patterns
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).
Scope: agent:Arin
core operating rules
The recurring L10 David runs with Dan is the SNEEZE IT meeting. Its board is Sneeze It only: the company/Leadership-team rocks (team c1e1a485), the Sneeze It scorecard tiles (Crystal, Arin, Havok, CCM), and the Sneeze It issues board. OTP product and engineering work is NOT a Sneeze It signal and does not go on that board unless David puts it there. When David's personal update touches OTP, log it as a headline, ask what he needs for it, and move on. Whatever Dan worked on that week is not automatically an agenda item; the agenda is the company's board.
Why: Dan spent the prior session deep in OTP mobile engineering and carried that context into the meeting as if it were the agenda. That is context bleed, not facilitation: it hands David a board reflecting what the agent worked on rather than what the company must decide. It also spends the scarcest thing in the meeting, David's attention, on analysis he did not ask for, in a meeting whose purpose is the agency's rocks, scorecard and issues. The facilitator's job is to hold the company's board, and to know which company's board it is.
Failure mode: Dan drifted the SNEEZE IT L10 into an OTP-product meeting. Two ways: (1) put OTP product/engineering work (mobile spec, merged PR waves, remaining Wave 3 decisions) on the board as a "signal" alongside Sneeze It's scorecard and rocks; (2) when David gave his personal update mentioning two high-value calls, Dan converted it into an OTP strategy analysis (is the PE group circling OTP or the agency, is the charity an OTP lighthouse account in a new vertical) instead of receiving them as headlines in the Sneeze It meeting. David: "this is just my personal update for Sneeze It our L10 is sneeze it, did you forget?"
Scope: agent:Dan
Headlines/personal update round is exactly TWO items per person: ONE personal, ONE business, both POSITIVE, one or two sentences each. It is a good-news round that sets the tone -- it is not a confessional, a self-audit, or a place to surface problems. Problems belong in Issues/IDS later in the agenda. Separately: facilitate ONE SECTION AT A TIME. Present the current section, get David's response, then move to the next. Do not lay the whole board (scorecard + rocks + issues + signals) out in a single message; that is a briefing document, not a facilitated meeting.
Why: The headline round exists to open the meeting on momentum -- wins are what make a team willing to look hard at problems in the next section. A facilitator who opens with his own failures inverts that and drags the tone down before the work starts. And dumping the full board at once removes the human's ability to steer: David cannot react section by section if every section arrives simultaneously, which is the same railroading failure as pre-framing decisions (L299), just delivered as volume instead of conclusions.
Failure mode: Dan gave a personal update that was long, self-critical and multi-part (three git errors, two misses in the meeting, a flag about his own KPI), and separately kept dumping the ENTIRE meeting board in one message instead of running sections sequentially. David: "personal update should be positive, lets follow EOS one thing at a time 1 personal one business".
Scope: agent:Dan
failure patterns
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".
Scope: agent:Dan
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?".
Scope: agent:Dan
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."
Scope: agent:Dan
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.
Scope: agent:Dan
operational heuristics
Before a swamp send, reconcile the changelog against the week's real PRs (git log origin/main --since since the last issue, filter to feat/ and customer-facing) and write entries for anything unlogged -- do not assume changelog.ts is complete. When the shared repo is contested by a concurrent session, do all changelog authoring in an ISOLATED git worktree (git worktree add off origin/main, symlink node_modules to reuse deps), PR it, merge via gh after CI is green, and run the REAL send from the worktree -- never edit or send from the shared working tree. Date drop-wave entries to the issue's MONDAY anchor (the sender's window upper bound), NOT to the send day: entries dated the Tuesday send day render as future and hide, and getRecentEntries (OS-today) masks this in preflight.
Why: The changelog is the single source of truth for both /whats-new and the email; an unmaintained changelog silently undersells the product to every subscriber. On a machine with concurrent agent sessions, the shared working tree is not safe for a multi-step author+send; a worktree makes the work deterministic and collision-proof. The Monday-anchor date rule is invisible until a dated-Tuesday entry silently disappears from the send.
Failure mode: SUCCESS: Swamp #29 -- two non-obvious operational wins. (1) The digest only reflects changelog.ts, so a big shipping week (~14 features) went out as "2 things" because most PRs never got changelog entries; David caught it. (2) Running the swamp send while another session was actively branch-switching in ~/otp-platform stranded an early commit on a feature branch and made a direct push to protected main a no-op.
Scope: agent:Swamp
failure patterns
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.
Scope: agent:Conatus
operational heuristics
For any EJS page whose logic lives in an inline script, add a test that parses every inline script with new Function() and fails the build on a syntax error, then prove the tripwire by running it against the broken version before keeping it. tsc cannot see inside a template and EJS renders a broken string happily.
Why: It was caught only by driving the actual rendered page in a headless browser, not by tsc, lint, or 1204 passing tests. First-run screens are where paying customers land, and a dead one is invisible from the server side.
Failure mode: SUCCESS: Conatus found OTP onboarding Door 4 (Give Ollie everything, PR #232, shipped 2026-07-19) had been completely dead in production since Sunday. onboarding-import.ejs line 214 had an apostrophe inside a single-quoted JS string, so the browser discarded the whole inline script and the file drop, analyze and commit buttons did nothing, silently, for every new customer who picked that door.
Scope: agent:Conatus
David: A2P pages do not allow form fills. Remove the lead form from A2P review landing pages entirely. Keep the SMS disclosure, the Privacy Policy and Terms links, and the registered business name and address on the page, because those are what carriers actually read. Route the quote path to phone plus the GHL chat widget. Reword any disclosure copy referencing "check the box above" so the page does not describe a mechanism that no longer exists.
Why: The form was the architecture of these pages, so this is not cosmetic: it orphans the consent audit trail and the POST /:slug/lead route, and it changes what the A2P campaign registration can declare as its opt-in method. Getting it wrong in either direction risks 10DLC rejection, which blocks SMS for the client entirely. The pattern repeats for every future A2P location page.
Failure mode: Built the Dryer Vent Squad A2P landing pages (Katy, DFW) around a web lead form with an optional SMS consent checkbox, treating that form as the opt-in proof mechanism for A2P/10DLC review, plus a consent audit trail behind it (consent.js and sms_consent_text/timestamp/IP/user-agent captured on POST /:slug/lead).
Scope: agent:Claude
The unbilled-spend sweep (billing-report step 3b) reads ~/.claude/billing/sweep-exclusions.json and drops those account IDs from the Review tab entirely. Use it for accounts that spend on our Google/Meta but are NOT billed on % of ad spend (white-label / flat-fee). Added 2026-07-23 per David: Phillip Jeffries, M.V. Parker Law, Champy's Chicken (+Nashville), Emily Shalant, Jet City Blinds, J&K Engines, Meyer Law, True Path, GettaMeeting, Lazzara Law, Studstill Firm. Only add IDs here on David's explicit instruction. Separate from the Clients-tab dont_bill mode (which still shows a DO NOT BILL row on the Billing output).
Why: Without a persistent list these 12 accounts (~$24.4K/mo) resurface as "confirm arrangement" in every monthly sweep, wasting David's review time. The file makes the exclusion durable across sessions.
Failure mode: SUCCESS: Billing sweep now has a persistent white-label exclusion list
Scope: agent:Radar
failure patterns
(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.
Scope: agent:billing-report
core operating rules
Sneeze It bills a % of BOTH Google and Meta ad spend. An unmapped Google account for an existing client is not a pending question -- it is unbilled revenue, and every month it stays unmapped is money we never invoice. When the sweep surfaces a Google account whose name clearly matches a client already on the billing list (e.g. "POWERHOUSE Fitness Stratford CT" -> "Powerhouse Gym - Bridgeport/Stratford CT", "beem Light Sauna - Macon" -> "beem Macon (GA)"), map it to that client at the client's existing rate rather than parking it on Review. Reserve Review for accounts with genuinely no matching client. Write mappings to the Clients tab of the billing sheet (column E = Google IDs), not just local clients.json -- step 2 of the report re-syncs from the sheet and would otherwise wipe a local-only fix on the next run.
Why: Five Google accounts sat unmapped and unbilled: Powerhouse $975.68, GLO30 Columbus $301.85, beem Macon $116.19, beem N.Richardson $115.36, beem Bonaire $91.33. Mapping them moved July billing from $11,690 to $11,985 (+$295/mo, recurring). Treating billable spend as an open question instead of a mapping gap converts a data-entry task into ongoing revenue leakage. The correct default is: name matches an existing client -> map it and bill it.
Failure mode: On the billing report I treated unmapped Google ad accounts as an open question for David ("bill at 10%, or exclude?") and left them sitting on the Review tab month after month. Powerhouse Gym's Google account (2001047898, $975.68) had been flagged "REVIEW / confirm arrangement" since at least June while the client billed Meta-only. I also described the Google line as "correctly escalated, not lost," which was wrong framing.
Scope: agent:billing-report
failure patterns
(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.
Scope: agent:billing-report
core operating rules
Match every client against the FULL live account inventory on both platforms (all statuses, all spend levels including $0), not just accounts with spend this month. Map on exact account-name match regardless of current spend, so the mapping is in place before the money moves. Treat the Notes column as a historical log, never as a source of truth about current platform coverage - re-derive coverage from the live account list each run. Also: HTTP 403 accounts are UNKNOWN, not zero; say so explicitly rather than letting them read as verified-zero.
Why: Sneeze It bills a % of both Google and Meta spend, so an unmapped account is pure revenue leakage the moment it activates. Verified 2026-07-24: sweeping the full inventory surfaced 17 additional client-owned accounts that a spend>0 sweep completely missed because they were all at $0 that month. Zero billing impact in July, but every one of them would have leaked silently the first month it spent - which is exactly how Powerhouse's Google account went unbilled for over a month.
Failure mode: The billing sweep only checked ad accounts with spend > 0 in the current month, and I trusted the "Meta only" text in the Clients tab Notes column as if it were current fact. Both are wrong. David pointed out those notes are historical - they describe how a client STARTED, not how it is now. A client can gain a Google account at any time; if that account happens to sit at $0 during the month I run the sweep, it never surfaces, the stale note keeps saying "Meta only", and the moment it starts spending we bill nothing.
Scope: agent:billing-report
operational heuristics
Three patterns for the OTP recorder. (1) "No way to record a second time" is TWO bugs: the missing UI affordance AND a server ingest that overwrites (ingestTranscriptOneShot did set({transcript})). Fix both or the feature silently destroys data; recording ingests now pass mode:'append', idempotent on the tail so worker retries cannot double-append, while paste/import stays 'replace'. (2) "Mic recorded silence after a long pause" on a phone is a dead MediaStreamTrack (readyState 'ended' or muted): MediaRecorder.resume() succeeds and records nothing. Recover by swapping in a fresh getUserMedia stream, and ALWAYS flush the retired recorder's final chunk BEFORE bumping the server segment number, or the old container's tail bytes land in the new segment and corrupt it. Auto-recover only when the track is provably dead; when it is alive but silent, warn and offer a button, since a genuinely quiet room looks identical. (3) Check git log before rebuilding from a support ticket: background transcription had already shipped that morning, so ticket 3 needed a sequential-to-concurrent R2 upload fix plus a crash fix, not a rebuild.
Why: The recorder holds the customer's only copy of a meeting until upload completes, so every bug in it costs unrecoverable audio, and two shipped in one day. The widget is inline JS in an EJS partial with no import path, which is why it went untested; it can now be tested by rendering the partial, extracting the script, and running it in a vm against a fake MediaRecorder (src/views/partials/meeting/audio-record.test.ts). Use that harness for future recorder changes.
Failure mode: SUCCESS: Claude (OTP dev) fixed three meeting-recording tickets (PR #315) at root cause, and caught that PR #309 from earlier the same day left savedStatus() calling itself on the non-pending branch (a stack overflow that swallowed the save confirmation on the R2-off path) because the recorder widget's inline JS had no tests.
Scope: agent:Claude (OTP dev)
When editing OTP trust/security claims, edit src/config/trust.ts (the file the /trust route imports and ships in the dist image). trust.yaml at the repo root is only the audit copy carrying `# source:` code citations for legal; nothing reads it at runtime. The two had already drifted (legalEntity OTP,LLC vs OrgTP,LLC; a stale lastUpdate; and — critically — trust.ts shipped a prohibited EOS mark "L10" that trust.yaml did not). Always mirror any claim change into BOTH files, and treat trust.ts as authoritative for what the public actually sees.
Why: A trademark-compliance violation (EOS "L10" mark) was live on the lawyer-facing trust page for weeks because the de-EOS pass only fixed trust.yaml, which never ships. Editing the audit copy feels like fixing the page but changes nothing a visitor sees. This is a recurring drift trap worth a permanent CI equivalence check between the two files.
Failure mode: SUCCESS: the public /trust page renders src/config/trust.ts, NOT trust.yaml — the "source of truth" file never loads at runtime
Scope: agent:Claude
Never position OTP (or Ollie) as an employee. A founder, and even an employee, does not want another employee; they want to know the organization is held. "Employee" also imports employee mentality: waits to be told, owns a lane not the whole. OTP guides the organization. Frame it as the guide/what holds the company, positioned between employee (too small), guru (too big), and co-founder (not really).
Why: Positioning error at the core-promise level: hiring an employee increases a founder's load (managing, explaining, checking); OTP's promise must reduce the holding. Wrong noun poisons every downstream page, price, and demo.
Failure mode: Framed OTP's destination as "the first employee a company hires whose job is to remember" in the product discovery brief.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
operational heuristics
When a vendor was chosen specifically to absorb an operational burden, do not propose solutions that hand that burden back, even when the vendor documents them. Default order for the consent-screen concern: (1) own the story in product copy (tell users they will see Composio, frame it as the security vault), (2) at most white-label the two or three marquee providers if branding ever matters commercially, (3) never the whole catalog.
Why: Vendor docs happily describe features that shift work back onto the customer. The right frame is the original build-vs-buy decision: Composio IS the OAuth team. Recommendations that quietly re-hire that job in-house waste the subscription and David's time.
Failure mode: Asked how to get OTP branding on the Composio OAuth consent screen, Claude recommended white-labeling via custom auth configs, which means creating and maintaining an OTP-owned OAuth app per provider (Slack app review, Google verification, secret rotation, scope upkeep). David rejected it: the reason OTP uses Composio at all is ONE managed OAuth surface across hundreds of products; a per-provider OAuth app pipeline recreates the exact burden Composio was chosen to eliminate.
Scope: agent:Claude
agent roles and authority
The GHL OTP pipeline holds prospects: Clay-sourced coaches, meeting/demo attendees, inbound leads. Existing signups never become GHL contacts. Clerk/user data is used only to (a) detect when a tracked prospect converts (move their opportunity to Signed Up / won) and (b) enrich engagement signals on prospects. Dawson's queue is prospect follow-up, not user nurture.
Why: Two systems covering the same people creates double-touching and noise; the actual gap in the sales process is prospect visibility and follow-up, which nothing else covers. Signup is the pipeline's finish line, not its content.
Failure mode: OTP-Sales Lane 1 was built to bulk-import existing OTP signups (Clerk users) into GHL and queue re-engagement touches on existing users. David corrected: existing users are already covered (product, lifecycle emails, Swamp); the GHL pipeline is for PROSPECTS only — people who have not signed up yet.
Scope: agent:OTP-Sales
core operating rules
The no-eosworldwide-addresses exclusion no longer applies to the GHL "Clay Email Send" nurture channel — EOS coaches are explicitly in-ICP for it (David 2026-07-26). The exclusion survives only where the copy or channel still carries risk; check the current copy for EOS marks before any send, but do not remove EOS addresses from nurture.
Why: The original rule existed because outreach copy used EOS marks during an open trademark compliance matter. The copy is now mark-free, so the audience exclusion lost its reason; keeping it would exclude the best-fit coach ICP from the pipeline.
Failure mode: OTP-Sales flagged 19 @eosworldwide.com contacts in the otp nurture sequence as a violation of the permanent no-EOS-addresses rule and moved to de-tag them. David overruled: EOS implementers/coaches ARE a prime target for OTP nurture, and with all OTP copy de-EOS'd there is no remaining trademark exposure.
Scope: agent:OTP-Sales
human ai boundary conditions
Sneeze-Sales creates contacts, opportunities, tags, notes, and tasks in GHL but NEVER applies the sequence/nurture trigger tag — sequence enrollment is David's manual step after he vets each contact. ICP for Sneeze It prospecting: FRANCHISORS in fitness, health and wellness, med spa, and any membership-type organization.
Why: Auto-enrollment removes the human gate that protects existing clients and active relationships from cold sequences. David's manual vetting before enrollment is the safety mechanism; the agent's job ends at a clean, deduplicated contact load. The franchisor ICP keeps the pipeline focused on multi-unit expansion revenue.
Failure mode: The first /sneeze-sales spec copied the OTP pattern of auto-applying the nurture tag at contact creation, which would auto-enroll new prospects in the email sequence. David corrected: for Sneeze It, the agent only loads contacts into GHL; David runs the sequence himself so nothing is ever sent to a customer or someone he is already talking to. The spec also lacked the Sneeze It ICP.
Scope: agent:Sneeze-Sales
operational heuristics
The company boundary applies to every artifact that feeds Ollie, not just what is said in the room. When writing a meeting record for the Sneeze It L10, describe mechanisms in company-neutral operational terms (the meeting pipeline, the prep gate, the record path) and keep OTP product identifiers (PR numbers, endpoints, feature ship dates) out of the record entirely. Before pushing any record via agent-record, scan it with the same company-mismatch check the preflight applies to the board.
Why: Ollie's insight renders inside the Sneeze It meeting, so a record contaminated with OTP content produces a contaminated insight automatically, one week later, with no human in the loop. The boundary check must move upstream to where the source is authored or the violation recurs on autopilot.
Failure mode: Dan wrote the 7/20 agent-record for the Sneeze It L10 full of OTP product identifiers (PR #154, the agent-record endpoint, ship dates), so the Ollie Insight generated from it reads as OTP product narrative inside the Sneeze It meeting. David caught it live on 7/27: "Ollie still thinks OTP and Sneeze It are one." The context-bleed boundary was enforced in live speech but not at record-writing time, and the record is the insight's source.
Scope: agent:Dan
Prep does not end at the Slack brief. Any signal the brief nominates for IDS gets pushed to the OTP board as a ticket (otp-issue.sh, with teamId, AI Army = 065d1d4b) BEFORE the meeting, so David opens the meeting with the issues already loaded and workable in-product. The Slack brief is the narrative; the board is the working surface.
Why: The meeting runs inside OTP, so an issue that exists only in Slack is invisible at the moment of solving. This is the same source-of-truth lesson as L060 applied to prep outputs, not just prep inputs.
Failure mode: Dan surfaced pre-meeting signals (Pulse dark, pipeline shape, HiTone, overdue queue items) only in the Slack prep brief. David at the 7/27 L10: "you should have wrote those signals in OTP, too late now." Signals that deserve IDS never landed as tickets on the AI Army board, so the meeting could not work them in-product.
Scope: agent:Dan
When David closes an alert as handled, retire the RULE that generates it, not just the instance. For HiTone: edit the CLAUDE.md trigger line and coach-report spec to read "billing confirmed active since Jul 2026, do not flag on spend." In general: trace any recurring alert to the config line that emits it and fix that line, or the alert regenerates forever.
Why: A closed instance with a live trigger is an alert factory. It spends David's attention on the same resolved question weekly and erodes trust in real billing flags.
Failure mode: HiTone billing keeps re-surfacing to David even though he confirmed billing is correct and active (Jun 29, and again 7/27: "HiTone is being billed, you ask about that a lot"). Root cause: the BILLING TRIGGER rule still lives in CLAUDE.md's active-clients list and in the coach-report spec, so any agent that reads the config re-fires the flag whenever HiTone spend appears. The Jun 29 closure was recorded in the rocks file, but the upstream trigger rule was never retired.
Scope: agent:Dan
Prep must scan for WON/signed revenue explicitly, not just open pipeline: GHL won opportunities across ALL pipelines since the last meeting, plus Proposify signed events, categorized new/expansion/reactivation. Signed expansion revenue is the Q3 headline metric; it leads the scorecard section, and its dollar values get pushed to the manual Expansion tile the same morning. A tile with no automated source still gets its value entered at prep time from the won-deal scan; manual source does not mean no value.
Why: The board exists to catch exactly this: revenue proving or disproving the quarterly thesis. A prep that inventories dead tiles but misses live signed money reports the plumbing and skips the water. David finding revenue wins that his facilitator missed inverts the entire point of the seat.
Failure mode: Prep missed two signed expansion deals (Glo30 and WOA franchise additional revenue) that David saw on the board himself and had to point out at the 7/27 meeting: "you skip that a lot, did you not see them?" The prep scan read open opportunities in one GHL pipeline and the Expansion KPI tile (manual, no values), so signed/won expansion revenue had no path into the brief. The single most strategy-relevant signal of the quarter, expansion revenue from existing accounts, was invisible to prep while five dead tiles got named in detail.
Scope: agent:Dan
Same-session capture rule for ALL agents: when you witness David build or ship something real (a dashboard, an integration, a signed deal, a process), record it THAT session: a headline line in the daily note, and a todo or KPI value in OTP if money or a rock is touched. Do not wait for the weekly meeting; the builder remembering to report is not a capture mechanism. Dan additionally runs a Shipped This Week sweep every Monday prep as the backstop.
Why: Work done outside meetings is systematically invisible to a meeting-based OS, and the founder's most valuable hours happen outside meetings. Three instances surfaced in one meeting (dashboard, prep signals, signed revenue). Invisible work costs real money: David unknowingly built part of Bogdan's open reporting-cost rock.
Failure mode: David built a WOA dashboard for iCart (central database, less Zapier, better reporting) and the only witness was the AI in that session; no headline, todo, or record reached the operating system. David at the 7/27 meeting: "the only one that knows is you, this is a true failure of the operating system that needs to be reconciled." Same morning, two signed expansion deals (Glo30, WOA franchise) were also absent from every system of record.
Scope: agent:Dan
Facilitation = operating the product live. The moment a section starts, its artifact moves: an issue under discussion is verified rendering on the board before discussing it; the moment David decides, the ticket is solved with its resolution, the todo is created, the KPI is pushed, in that minute, not at conclude. After every state change, verify the rendered surface. Conclude should be a read-back of changes already made, never a batch of pending writes.
Why: A meeting inside OTP is only real if the product state changes while the humans watch. Deferred writes recreate the mirror-drift problem inside a single meeting, and David cannot trust a board that lags the conversation.
Failure mode: Dan facilitated IDS discussion in chat but did not move the meeting's product surfaces in real time: the Pulse issue being discussed was not visible in the meeting's IDS section, and the already-solved invisible-work issue still sat open on the board. David 7/27: "you should be moving the meeting along like a human, doing things as we work."
Scope: agent:Dan
The needle test has a step zero: before wiring, fixing, or reporting any KPI, confirm the program/offer it measures still exists in the business, by asking David or checking recent revenue/activity, not config files. A dead program's tile is retired, not wired, and its language gets swept from all agent config at source (L099 pattern) so no agent rebuilds it. Guarantee/T20 program: DEAD as of 2026-07-27.
Why: Config outlives strategy. Wiring effort spent on a dead program's metric is worse than a dead tile, it would have shipped a number that misrepresents the business as still running an offer it killed, and every agent reading the tile would have inherited the fiction.
Failure mode: Dan was one step from wiring the guarantee-clients-retained KPI (emit line written, Tally about to fire) when David said the guarantee program is dead and no longer offered. The wiring work treated the tile's PROGRAM as alive because the config said so; nobody had asked whether the business still runs the thing the tile measures.
Scope: agent:Dan
When a media element silently stalls (networkState LOADING, error null, no console output), suspect CSP: a 302 redirect from a same-origin playback route to a cross-origin storage URL violates media-src (falling back to default-src 'self') and Chrome blocks it with zero surfaced errors. Diagnose live by attaching a securitypolicyviolation listener and probing a known cross-origin media URL. Fix: add the presigned bucket origin to media-src, computed from storage config at boot, never hardcoded.
Why: This failure mode is invisible by design (no element error, no console noise) and the natural debugging paths (storage probes, presigned URL tests, range requests) all pass, sending you everywhere except the CSP header. Cost about an hour across two sessions; the probe technique turns it into a 2-minute check.
Failure mode: SUCCESS: Claude/Conatus diagnosed why OTP meeting recordings never played in the browser (player stuck at 0:00, click did nothing) while server-side storage checks all passed.
Scope: agent:Conatus
Never invent a person's first name from an email address or initial. If the name is not stated, refer to them by the email address or ask David, and only record a name once confirmed.
Why: Guessed names propagate into client-facing artifacts (emails, dashboards, docs) and getting a client's name wrong damages trust; an email initial is not evidence of a name.
Failure mode: Claude inferred the Drybar Ballston client's first name as "Jodi" from the email address jsterling@sterlingcapitalllc.com and used it in the summary, credentials file, and memory. The client is Julie Sterling.
Scope: agent:Claude
`ghl.sh update-opp <oppId> <stage>` with the optional value argument omitted hits an inline Python syntax error (`data['monetaryValue'] = ` with nothing after it), so the stage payload is never built: the opp's updatedAt bumps but the stage does NOT change, and nothing errors loudly. Workaround until ghl.sh is fixed: always pass the value explicitly, e.g. `update-opp <id> sql 0` (confirm the opp's current monetaryValue first so you do not overwrite a real value). Always verify stage changes by re-reading the opportunity after the write.
Why: A stage move that silently no-ops corrupts the pipeline of record without any error signal. Both /otp-sales and /sneeze-sales route stage moves through this command; without the verify-after-write habit the bug would have shipped 2 phantom SQL bumps today.
Failure mode: SUCCESS: Sneeze-Sales found and worked around a silent ghl.sh write failure
Scope: agent:Sneeze-Sales
Before bumping a stage or queueing a task on any reply, check the PERSON and company against the active client list (CLAUDE.md), pepper-clients.md, and known client people, not just the cold-load exclusion at contact creation. Jordan Anderson is a client (Workout Anytime / Proof Fitness): never prospect-touch him on any domain. Replies inside threads a team member already owns (for example Zeynep scheduling) get no David task; the owner handles it. A reply landing in the prospect book is a signal to verify WHO it is, not proof they are a prospect.
Why: The prospects-only law fails at the edges: client people reply from domains that sit in the prospect book because franchisee outreach and client domains overlap (WOA). A wrong SQL bump plus a David task double-touches a client relationship and burns David's queue on non-sales work.
Failure mode: Sneeze-Sales treated Jordan Anderson (Workout Anytime) as a prospect: bumped his opp MQL to SQL on an inbound reply and queued David a respond-within-24h task. David corrected: Jordan Anderson is a client. Also queued David a confirm task on Lindsey (Fitness Factory) when Zeynep already owns that thread.
Scope: agent:Sneeze-Sales
When an OAuth integration fails silently, verify each layer with direct probes instead of reasoning from app behavior: (1) print env var names with cat -v, since a pasted quote becomes part of the variable NAME and the app reads undefined (Railway kv showed "GOOGLE_CALENDAR_OAUTH_CLIENT_ID with a literal leading quote); (2) test client credentials against the provider token endpoint with a bogus auth code, since the error distinguishes exactly: invalid_client means bad id/secret, invalid_grant Malformed auth code means credentials are VALID; (3) treat the database as ground truth for whether a flow completed, since users saying connected can mean a different surface (Composio integrations page vs the calendar card).
Why: Three probe layers turned what could have been hours of guessing into minutes: no callback in logs proved the flow died at Google, cat -v exposed the quote character, and the bogus-code token probe verified the replacement secret BEFORE the user retried, avoiding another failed round trip. Reusable for every OAuth integration OTP adds (Microsoft, Zoom, future providers).
Failure mode: SUCCESS: Claude shipped Recall calendar auto-join and debugged two invisible OAuth config failures the same afternoon (quoted env var name, invalid client secret)
Scope: agent:Claude
failure patterns
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
Scope: agent:Claude
operational heuristics
When graduating a feature out of Labs, grep the whole repo for the feature key and isFeatureEnabledForOrg calls before merging; every gate (page, API, scheduler, MCP tools) must come off in the same PR. A fail-closed flag check on a deleted key silently disables the feature for everyone, which reads as random 404s, not as a flag problem.
Why: Fail-closed gating is correct security posture, but it means catalog removal IS a kill switch. The bug shipped invisible because the page worked while the API did not, and the generic 404 body hid the cause; the honest error message shipped hours earlier (names the workspace and feature) is what made the real diagnosis possible.
Failure mode: Projects went GA (PR #347 removed it from the Labs catalog and the page gate) but the API routes kept gating on the removed key; isFeatureEnabledForOrg fails closed on unknown keys, so all project CRUD 404'd platform-wide for two days until Dawson and David hit it
Scope: agent:Claude
coordination patterns
Agent-owned issues belong on the shared meeting IDS list, full stop — David ruled 7/28: "Humans and Agents work as one." When an agent raises an IDS issue for a team meeting, it must land on that team's board (correct team_id) and render in the meeting; when an issue is solved during a live meeting, stamp solved_in_meeting_id so the solve is part of the meeting record.
Why: OTP's core differentiator is agents as first-class seats on the chart. A meeting surface that hides agent-owned work contradicts what the product says it does (Constitution axiom: reconcile what it SAYS with what it DOES) and made real coordination work invisible in a dogfooded meeting.
Failure mode: The meeting IDS section in OTP filtered out ALL agent-owned issues (owner_entity_type='agent' or AGT_* owner) by an old "EOS leadership meetings are humans-only" convention, so the 7/27 Dan L10 (an all-agent team) rendered a blank IDS while 2 open team tickets existed on the board. Agents were also solving issues via MCP set_ids_status without stamping solved_in_meeting_id, so live solves never showed in the meeting's solved list.
Scope: agent:Dan
operational heuristics
Any otp-platform endpoint that scopes data or authz by getAuth(request).userId is broken under impersonation. The rule: gate and scope by the EFFECTIVE viewer (request.impersonation.as when active, else auth.userId), return/audit with the RAW session id, and for context-pinning actions (org switch) re-issue the impersonation cookie via startImpersonation rather than moving the admin's own cookies. When reviewing or writing any new route, grep for getAuth(request).userId used in a WHERE clause — each one is a latent impersonation bug.
Why: Impersonation is how David supports customers (view-as Tom, Kristen, etc.). Every raw-session usage silently shows the admin's data under the customer's banner or 403s the customer's own surfaces — a privacy leak in one direction and a support dead-end in the other. Fixed instances: dashboard (2026-06-02), PRs #381, #382, #384 (2026-07-28).
Failure mode: SUCCESS: Dan identified a recurring defect class in otp-platform — four separate surfaces broke under super-admin impersonation in one day (portfolio pages listing the admin's portfolios, portfolio API 403ing "Could not load team", the sidebar org label showing the admin's org, and the org switcher 403ing "Could not switch organization"), all with the same root cause.
Scope: agent:Dan
failure patterns
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.
Scope: agent:Swamp
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!"
Scope: agent:Swamp
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.
Scope: agent:Swamp
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.
Scope: agent:Ollie
operational heuristics
Do not treat a missing or small wallet balance as a signal of anything. An org with no wallet is simply not an active OTP user, so its balance says nothing about product readiness. New orgs are seeded with $25 of credit to incentivise starting, so a funded wallet is the default going forward rather than a hurdle. When assessing whether a metered feature is usable, filter to orgs with real activity and check the NON-wallet prerequisites, since those are the ones that actually gate anyone.
Why: Reporting wallet balances as blockers manufactures work out of the ordinary shape of the user base: most rows in that table are dormant signups, not stuck customers. It also buries the prerequisite that does bite, because a real blocker listed next to four fake ones reads as one item in a list instead of the single thing to fix.
Failure mode: Flagged orgs with low or missing wallet balances as a readiness problem for OTP scheduling, treating wallet funding as a live blocker worth David's attention.
Scope: agent:Conatus
failure patterns
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.
Scope: agent:Ollie
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.
Scope: agent:Conatus
operational heuristics
Before retiring a scarcity, cohort or badge claim, count each cohort in the database and check whether one phrase names several cohorts. Shared wording is not shared meaning. If counts contradict the instruction's premise, return to the human with the numbers instead of executing literally.
Why: Broad approval rests on an assumed premise. When the premise is partly false, literal execution silently destroys value: a deleted live offer throws no error, it just yields fewer signups. One query per cohort is cheaper than a loss nobody detects.
Failure mode: SUCCESS: Claude verified cohort counts before executing an approved codebase-wide sweep of OTP's "first 50" claim. It was closed for signups (50/50) but live for Founding Publishers (45/50) and Founding Partners (6/50). Literal execution would have deleted two accurate live offers.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
operational heuristics
For multi-agent feature builds: (1) research agents return structured briefs before any code, and briefs override the spec when they conflict (two detectors were impossible as specced: meetings have no booked-duration column, decisions are not rows). (2) Put all shared-file wiring (server.ts) in ONE sequential final task so parallel agents never collide. (3) Always run an independent fresh-context diff review before committing: it caught two honesty blockers the per-task verifications missed (ratified moves netting costs away; org-wide gains summed over subtree-scoped costs). (4) Discovery worth acting on: subscriptions.plan_rate is never written by any code path in otp-platform, so any revenue/cost feature reading it ships dark until billing populates it.
Why: The per-task agents were all green individually; only the cross-seam review found the invariant violations. Repo guard tests (private-issue leak scan, blueprint coverage) also fired exactly as designed, proving lint-style guard tests catch what unit tests cannot.
Failure mode: SUCCESS: Claude shipped OTP Impact Phase 1 (PR #427) via 11-agent build: parallel research briefs, wave execution with pure-function cores, then adversarial diff review before commit
Scope: agent:Claude
When adding a NEW page to an existing app, open a sibling page that already ships (for OTP admin surfaces, /admin/support) and copy its outer container, top padding and control classes verbatim before writing any markup. Do not hand-roll spacing from DESIGN.md tokens alone -- the tokens do not tell you the page-level offsets that keep content clear of the fixed header. For any default that is a money amount, confirm the number rather than inferring it from the option list order.
Why: A new page laid out from first principles looks subtly wrong in ways the author cannot see without loading it: header collisions and control scale only show up in a browser, not in typecheck, lint or design-lint, all of which passed. Copying a shipped sibling inherits every page-level decision already made and reviewed.
Failure mode: Built /admin/join-link with hand-rolled Tailwind layout (max-w-3xl, custom padding, custom input classes) instead of copying the container and control classes from an existing admin page. Result: the page header collided with the fixed top nav so the title was unreadable, and the form controls were oversized versus OTP's 32px control scale. Also picked $50 as the default starting credit without asking; David wants $25.
Scope: agent:Claude
When promoting any OTP Labs feature from beta to live, do THREE things, not one: (1) flip `status` in src/shared/lab-features.ts; (2) grep src/views for the feature's `surfaceUrl` -- if the ONLY link is the Labs-injected rail item, add a permanent entry to layouts/main.ejs in BOTH the `_sbItems` array and the mobile settings menu; (3) grep the page for stale "this is a Labs feature" banner copy pointing at a /settings/labs toggle that graduation just removed. Also verify any second, independent gate (e.g. an env check like recall-calendar.ts calendarIntegrationEnabled) and make the registry copy match what is actually configured in production -- check `railway variables --kv` rather than trusting the existing description.
Why: Graduation looks like a one-line status change and is not. The rail item, the page's own Labs banner, and any env-based second gate all key off the old state, so a naive flip can make a feature LESS reachable than it was in beta while appearing to ship it. Confirmed live: PR #437 shipped the flag plus the nav entry together, and the promoted page rendered correctly with the Calendar section visible and no Labs opt-in.
Failure mode: SUCCESS: Claude caught that graduating an OTP Labs feature from `beta` to `live` silently DELETES its left-rail nav item, which would have shipped calendar auto-join into being unreachable. `getOrgLabNavItems` (src/services/lab-features.ts) filters on `f.status === 'beta'`, so only beta features get a rail item injected. /settings/meeting-presence had no other link anywhere in src/views, so flipping the flag alone would have removed the only way to navigate to it.
Scope: agent:Claude
Four reusable rules for /coach-report and any Dash run. (1) When `meta-ads.sh token-check` returns Valid:False, do not report the portfolio as quiet: the CCM Stats sheet "Ad Spend" column carries the Meta-side spend for every call-centre project, so it is a working fallback for spend and leads. Label the source on the card. (2) `mcp__google-workspace__get_events` silently caps at max_results and truncates the NEWEST events, not the oldest. A 90-day pull capped at 250 returned nothing after Jun 30 and would have reported "no client meetings in July." Always check the max date in the response against time_max and re-pull in narrower windows. (3) Never average Google cost-per-conversion together with CCM cost-per-booked-appointment in a franchise network benchmark. Group the cost metric by source, keep only the largest comparable group, suppress the table when fewer than 2 comparable peers, and name the metric explicitly on the card. (4) When CCM shows appointments booked but zero shows, that is unconfirmed data, not a zero show rate. Do not report show rate; ask for confirmation instead.
Why: Each of these silently produces a confident wrong number in a client-facing artifact. The calendar cap fabricates a churn signal, the mixed-metric average makes a healthy location look 60x worse than a peer, and a missing Meta token makes a $136K/month portfolio look dead. Cards go to clients, so a wrong number costs trust directly.
Failure mode: SUCCESS: Dash /coach-report 2026-08-03 — shipped 50 cards with Meta Ads fully down, and caught two silent data traps that would have produced wrong client-facing numbers.
Scope: agent:Dash
Refines the CCM-fallback rule captured in L125. The CCM "Ad Spend" column matches Meta actuals closely (Rockstars Frisco $453.23 vs $452.54, Okeechobee $150.07 vs $151.09, China Grove $384.79 vs $388.21) but ONLY when the sheet has a complete row for every day in the window. WOA Winder read $133.98 against Meta's $303.08 because the project stopped appearing in the sheet after Jul 30. So: before using CCM as a Meta proxy, count the daily rows per project across the window and flag any project with fewer rows than days. A project that silently drops out of the sheet reads as a spend and lead collapse when it is a recording gap. Second lesson: do not attribute a lead decline to an ad-platform outage without checking delivery. Meta REPORTING was dark to us from Jul 27, but Meta DELIVERY was fine (portfolio leads only -9% week over week, spend flat), so the much larger per-project CCM declines were a recording or routing artefact, not an ad problem.
Why: The fallback is genuinely good enough to save a report, but only with the completeness check. Without it, a project that falls out of the sheet produces a fabricated churn signal that a coach would take to a client. And blaming a visible outage for an invisible decline is the easy wrong answer that stops the real investigation.
Failure mode: SUCCESS: Dash — Meta token restored 2026-08-03, and the CCM fallback used during the outage was measured against real Meta data once it came back. The fallback was accurate to within 1% on 3 of 4 spot-checked projects but understated WOA Winder by 56%.
Scope: agent:Dash
failure patterns
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.
Scope: agent:Dash
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.
Scope: agent:Dan
operational heuristics
Upload the coach report to Drive exactly ONCE per run, at the very end, after the validation sweep passes. Never upload an intermediate build, even when the intent is to re-upload a better one later, because the shared folder is read by coaches the moment a file lands. If a rebuild is genuinely needed after an upload, trash the superseded file in the same action rather than leaving both. Use update_drive_file with trashed=true (recoverable); neither Drive MCP exposes a hard delete. Verify by file size against what was generated locally before trashing anything, and get David's approval first since the folder is shared.
Why: A shared folder is a publishing channel, not a working directory. Every extra file is an opportunity for a coach to open the wrong numbers and take them to a client. The same duplicate pattern already exists on 2026-07-11, 2026-05-25, 2026-04-06 and 2026-03-10, so this is a recurring habit rather than a one-off.
Failure mode: Dash uploaded three files to the shared Coach Reports Drive folder in one morning, as the data improved from Meta-blind to full to corrected. Zeynep has reader access, so two of the three were coaches' paths to stale client numbers until David approved trashing them.
Scope: agent:Dash
Never read a green tile as evidence that the agent named on it is alive. Decoupling a KPI from its agent protects the number but removes the number's ability to report the agent's death, so agent liveness needs its own signal: check the shared-state file mtime alongside the tile before making any seat decision. When a seat review comes up, present tile value and agent liveness as two separate lines, never one.
Why: A seat can look healthy and be vacant. Here the 32.6% measured Erica and Amanda's human performance, not agent output, so the tile would have argued against repurposing a seat that had already stopped running. Seat decisions made on decoupled tiles are made on the wrong evidence, and the error is invisible because the metric is genuinely accurate about the thing it actually measures.
Failure mode: Dan argued in the 8/3 meeting that Arin's 32.6% appointment rate showed the Arin seat was working, so repurposing it was not urgent. That inference was wrong. The Arin KPI was deliberately decoupled from Arin-the-agent in June (source kind composio_action, reading the CCM sheet directly) so it would survive a repurpose. Meanwhile arin-latest.md had been stale for 287 hours, about 12 days. The agent was dark and its tile was green the entire time.
Scope: agent:Dan
failure patterns
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.
Scope: agent:Dan
human ai boundary conditions
Never describe an API-derived board, list, or count as complete. Say "N visible to me" rather than "N open", because private items are structurally invisible and their absence is undetectable from the API alone. When a count matters, or when a human's screen disagrees with yours, ask them to read their view or open the rendered page yourself. Any KPI sourced from an agent API query of issues silently undercounts by the private set and can never self-correct.
Why: This is a designed privacy boundary, not a defect, so it will never be fixed and must be worked around permanently. The danger is that it is invisible in exactly the way the rest of the day's failures were: the API returns a confident number with no signal that anything was withheld. An agent reporting "the board is clear" can be wrong while a human looks at three open items, including sensitive ones like team capacity and hiring, which are precisely the issues an agent should never assume away.
Failure mode: Dan reported an OTP issue count from the API and stated it as the board's true state. It was not. Confirmed on 2026-08-03 by loading the rendered admin view: three issues marked private appear on David's screen and are returned by no agent API call at all. The API applies issueVisibilityForSeats(resolveViewerSeatIds(request, org)), and an API key resolves to zero seats, so it fails closed to noPrivateIssues(). The agent-visible board is a strict SUBSET of the human board.
Scope: agent:Dan
failure patterns
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.
Scope: agent:Dan
operational heuristics
Before escalating any data problem to a vendor, query the vendor's live API for the object in question and reconcile it against our own source of truth. Here the API showed the project had held exactly 8 keywords since creation (first_keyword_added_at), so nothing was ever lost, and our own cluster map defined 28 pillars rather than the 43 the KPI asserted. The genuine vendor issue turned out to be a different and much larger one: 19 of 27 projects silently blocked on NO_QUOTA, including six client projects. Split the ticket: send the vendor only what the vendor actually owns.
Why: A false claim to a vendor costs credibility and burns the support cycle you need for the real issue. It also hides the internal defect behind a vendor excuse, so it never gets fixed. In this case verifying first both protected the vendor relationship and surfaced a client-delivery problem nobody had noticed.
Failure mode: An item routed to a vendor (Search Atlas) as "the vendor dropped our 43 tracked keywords to 8" was accepted at face value from an L10 without checking live vendor state first. Sending it would have been a false data-loss claim against the vendor.
Scope: agent:Beacon
When a measurement pipeline is broken, check whether the metric itself is the defect before repairing the plumbing. Beacon's KPI was "pillar keywords in top 10 (of 43)" on a domain whose pillar pages had launched six weeks earlier. That number reads 0 for quarters regardless of whether the work is excellent or abandoned, so repairing the vendor feed would have restored a tile that still said nothing. Test any KPI with two questions: can this number move within one review cycle, and if it moved would I trust what it means? Beacon failed both (its actual top-10 rankings were unrelated junk queries). The fix was to change the instrument to Google Search Console, which is free and already authorized, and the metric to pillar-cluster impressions, which moves weekly. Rename the existing tile in place via PATCH /api/v1/kpis/{id} rather than creating a new one, so the seat keeps its position and no dead tile lingers on the chart.
Why: A KPI that cannot move is not accountability, it is decoration, and it quietly consumes the attention a real metric would earn. This one also hid a genuine finding for six weeks: the pages were being surfaced 3,479 times and earning one click, which is a titles and intent problem no top-10 counter would ever reveal. For OTP specifically this is a Constitution matter, since the axiom is that an org reconciles what it says with what it does; running a fake KPI on OTP's own chart dogfoods the disease the product exists to cure.
Failure mode: SUCCESS: Beacon rewired from a vendor-dependent KPI that had never once produced a real number to a free first-party one that works.
Scope: agent:Beacon
After editing any .ejs or CSS in otp-platform, run `node scripts/design-lint.mjs` as well as tsc/tests/smoke:render. It has no npm script, so the standard local verify passes while CI's lint-and-type-check job fails. It counts DESIGN.md violations per file against scripts/design-lint-baseline.json and fails on any increase. When it fires, fold the new selector into the existing rule instead of running --update on the baseline.
Why: A view change can look fully verified locally and still red CI, costing a full round trip. On PR #459 a focus ring (not a resting shadow) tripped css-resting-shadow 6 -> 7; folding the picker into the existing input and focus rules fixed it and was what DESIGN.md wanted anyway, so the gate caught real design debt rather than noise.
Failure mode: SUCCESS: Claude found the otp-platform verify recipe is incomplete for any view change
Scope: agent:Claude
failure patterns
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
Scope: agent:Claude
operational heuristics
A bounce is never proof an account is fake. Before deleting any account, check three things: (1) does an auth-provider user exist, (2) when did they last sign in, (3) what data and pending invites hang off them. Then read the bounce SHAPE: a hard bounce in ~3 seconds means the domain or mailbox does not resolve, which usually points to a typo in an otherwise real address; a bounce 12-14 hours after send is a soft bounce from retry exhaustion (full mailbox, suspended account, reputation deferral) and the person is real. Report the split and get explicit confirmation before deleting anything that is not provably fake.
Why: Bounces cluster on real customers with mistyped addresses, not on fabricated signups. A bad email is a recoverable lead: One Jump's owner mistyped a subdomain and became unreachable for seven weeks, while the colleague he invited was reachable at the correct corporate domain the whole time. Treating "bounced" as "fake" deletes paying-customer-shaped signups and destroys the only evidence needed to win them back. Deletion is irreversible; suppression achieves the actual goal (stop the bouncing) at zero cost.
Failure mode: SUCCESS: Claude caught that a "delete these fake bounced accounts" request included a live customer org. Of three addresses flagged as fake, only one was: test@abctest.com had no Clerk user at all. business@mail.onejumpinc.com was Jessup Jong, owner of the live "One Jump" org, last signed in 3 weeks earlier, with a chart, team, meeting, and a pending invite to a colleague expiring in 5 days. Deleting as asked would have destroyed a real signup mid-onboarding, irreversibly.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
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.
Scope: agent:Arin
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.
Scope: agent:Radar
operational heuristics
Never write a file that other processes read with open(path,'w') plus write. Write to a temp file in the SAME directory via tempfile.mkstemp, then os.replace(), which is atomic on POSIX so readers see the old file or the new one and never a half-written one. Wrap it so the temp file is unlinked if the write throws. When you find one writer of a shared file doing this, grep for every other writer of the same file and fix them all; fixing one leaves the race intact. Verify with a concurrency test that reproduces the failure on the old code and shows zero on the new, rather than assuming. Note that mkstemp yields 0600, which tightens a credential file from the usual 0644 and is an improvement.
Why: A truncate-then-write race on a shared credential file is invisible in normal use and only appears under concurrency, so it presents as a flaky, unreproducible alarm on a load-bearing data source. That trains the operator to dismiss the monitoring. Worse, the failure mode is indistinguishable from a genuinely revoked token, so the real alarm and the false one look identical. Atomic replace removes the entire class of failure at the source rather than papering over it with retries.
Failure mode: SUCCESS: Radar traced an intermittent phantom "Google Ads token invalid" alarm to a non-atomic credential write, not to the token or the network. Three separate processes write ~/.claude/mcp-google-ads/google_ads_token.json (google-ads.sh, google_ads_server.py, auth_setup.py) and all three used open(path,'w') followed by write. That truncates the file first, so any concurrent reader json.load()s a partial document and throws. Because get_access_token ends in 2>/dev/null, the exception surfaced as an empty token and got reported as a dead credential. A hammer test measured 178 partial reads out of 400 writes, a 44% failure rate under contention.
Scope: agent:Radar
failure patterns
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.
Scope: agent:Swamp
operational heuristics
Before repainting any utility class in a view, grep it in src/styles for an !important clamp selector and move that hook in the same commit. Verify repaints with a pixel diff against a before-screenshot, never on a green lint run alone.
Why: A clamp keyed on a class name is an invisible coupling no test or linter can see, and cleaning up that class name is exactly the change that breaks it.
Failure mode: SUCCESS: Claude found OTP dashboard-daily hairline-row grammar was produced by an !important clamp in input.css keyed on the row wash class name. Repainting that class onto tokens silently detached every row from the clamp, restoring borders and radius and shifting padding. Tests and design-lint both stayed green.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Conatus
operational heuristics
For the Sneeze It AGENCY lane, outreach is account-based, not volume-based. The outreach that produced actual revenue (Beem, now a multi-location client) was individually researched and drafted into David's Gmail, one account at a time. Build the big list for SELECTION, not for sending: a master Google Sheet where every row carries enough context to be decidable in ten seconds, David marks an X on the rows he approves, and each approved account then gets real research and a bespoke Gmail draft. GHL becomes the record of the resulting deal, not the send engine. Do not templatize, do not sequence, do not meter.
Why: Sneeze It accounts are worth $44k to $136k a year each (HiTone is 8 locations at $5,472/mo; WOA is 13 club accounts at ~$136K/yr expansion). At that account value, thirty minutes of research per email is trivially correct economics, and 500 templated sends optimize the wrong variable entirely. The data agrees: 221 contacts sit parked at MQL against 11 that ever advanced, and the templated 19-contact batch sent 7/27 produced nothing measurable in nine days, while bespoke research-led outreach produced a real client. IMPORTANT SCOPE NOTE: this INVERTS the existing learning that says default to a multi-touch sequence rather than hand-personalized copy. That rule was derived from OTP coach outreach at list scale and remains correct there. It does NOT transfer to Sneeze It agency prospecting, where the ICP is small (roughly 800 franchisors) and each account is large. Check which lane you are in before choosing the artifact shape.
Failure mode: I diagnosed the Sneeze It cold-outreach problem as insufficient volume and proposed waves of 500 templated emails metered out through GHL at 25/day. I was optimizing for throughput when the evidence in front of me said throughput is exactly what has never worked.
Scope: agent:Sneeze-Sales
failure patterns
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.
Scope: agent:Claude
core operating rules
The OTP meeting bot name leads with Ollie, and the owner block is parenthesized Company first, then Person: 'Ollie Meeting Recorder - (Company / Person)'. Ollie is the product's name and comes first; the customer org outranks the individual inside the parens. When a support ticket proposes a name format, treat it as the requirement (who owns it, what it does), not as the literal string -- OTP's brand order wins.
Why: Ollie is the mascot and product identity; burying it at the end of the participant-list name subordinates the brand to the customer's org name in every external meeting the bot ever joins. Company-before-person also matches how the rest of OTP scopes identity (org, then member).
Failure mode: Built the OTP meeting bot's vendor-facing name as '<Person> / <Org> Meeting Recorder - Ollie' -- person first, org second, Ollie trailing at the end -- by following the support ticket's literal wording instead of confirming OTP's own naming order.
Scope: agent:Conatus
operational heuristics
Do not treat an open GHL opportunity in the Sneeze It Sales Funnel as evidence of a live relationship. David confirmed 2026-08-05 it is a graveyard: 221 of 234 open opportunities are parked at MQL and have never moved a stage. Only a WON opportunity (they became a client) is a hard block. Open, lost and abandoned opportunities are just a dated touch and should fall through to the COOLING or REACTIVATION tier based on how long ago that touch was. Separately, out-of-ICP companies (equipment manufacturers like The Abs Company, food, dental) belong in a dedicated ICP-exclusion file, not in do-not-blast.md, which exists for compliance and unsubscribe obligations.
Why: Blocking on open opportunities inverted the meaning of the pipeline. A stage that nothing ever leaves is a record of who we once imported, not who we are talking to, so using it as a suppression signal removes the exact brands most worth re-approaching. Reading a dead pipeline as a live one is the same class of error as reading a capped API response as a complete one: the data is technically present but means something different from what its name suggests. Check whether records in a stage actually move before you let that stage gate behaviour.
Failure mode: The suppression builder treated any OPEN opportunity in the Sneeze It Sales Funnel as a hard block, on the assumption that an open opportunity means a live deal that must not be cold-emailed.
Scope: agent:Sneeze-Sales
David explicitly authorized DIRECT SENDING for the master-prospect-sheet lane on 2026-08-05, after I raised the risk and he reaffirmed. The human gate moved rather than disappeared: it is now the X he types in column A of the Sneeze It Master Prospect List, which is a per-company approval made before any email exists. Hard limits he set: max 30 sends per day, from dsteel@sneeze.it, one scheduled run per day, with a manual override command to run on demand. Every send stamps the sheet with sent status and date so a no-reply follow-up can fire ~30 days later. L096 is NOT repealed anywhere else: the `/sneeze-sales` GHL harvest still never applies a sequence tag and still never sends. Check which lane you are in.
Why: The reason behind L096 was never "an agent must not send" as a principle; it was that no cold email should reach a client or someone David is already talking to. A per-row human X satisfies that intent more directly than sequence enrollment did. The risk that remains is different and worth naming to a future operator: the X approves the COMPANY, not the SENTENCE, so nobody reads the email before it goes. That makes research accuracy and suppression freshness load-bearing in a way they were not when David hand-sent drafts. Rebuild the suppression list before any run, and never send to a BLOCKED domain, a bounced address, or an unsubscribe, regardless of what the sheet says.
Failure mode: Standing rules said the Sneeze It outreach agent never sends email: L096 required David to personally vet each contact and enroll them in the sequence himself, and the morning-pass floor says "prepare drafts, never fire them." Under the new sheet-driven process those rules would block the whole loop.
Scope: agent:Sneeze-Sales
In-home care and senior care FRANCHISORS are IN ICP for Sneeze It as of 2026-08-05 (David: "low target and worth a try"). The qualifying trait is a multi-location franchisor with a real lead-generation budget, not a membership billing model. Do not hold or flag them. Because David framed it as a try rather than a conviction, tag these sends so their reply rate is measurable separately from fitness instead of being blended into one number: an experiment you cannot read the result of is not an experiment. The residential end (nursing homes, assisted living facilities) is still untested and was not part of this ruling.
Why: The ICP was written as a description of existing clients, who happen to be membership gyms, and I applied it as a boundary on who could ever be a client. Those are different things. What Sneeze It actually sells is paid lead generation plus a call center that works the leads fast, and any franchisor buying leads for local operators has that problem regardless of how they bill their customers. Watch for this shape generally: an ICP inferred from the current book will keep reproducing the current book, and the operator is usually the one who can see past it.
Failure mode: I flagged seven in-home care franchisors (Home Instead, Assisting Hands, Always Best Care, Comfort Keepers, Senior Helpers, Synergy HomeCare, FirstLight) as out of ICP and held them from sending, reading the Sneeze It ICP as membership-model fitness and wellness only and treating the inherited Nick-era "senior living" exclusion as covering them.
Scope: agent:Sneeze-Sales
When a resource has an access rule on its primary page, extract that rule into ONE shared helper (canReadMeeting in services/meeting-read-access.ts) and apply it at every read surface, list AND single-row, instead of letting each endpoint hand-roll a partial check. Also: Ollie chat executes tools via app.inject with the user's own session (makeSessionOtpFetch), so fixing the HTTP endpoints automatically scopes Ollie's chat answers — no separate AI-context fix needed. Org-API-key callers carry no member row and stay org-wide by design.
Why: Access-control drift is a leak class, not a one-off: every new read surface (followups, exports, recordings, captures panel) shipped without the team gate because the rule lived inline in one page handler. A single source of truth makes the next surface safe by default, and knowing Ollie chat rides the user's session means endpoint-level authz is the one place to fix AI data exposure too.
Failure mode: SUCCESS: Conatus — root-caused critical R3V ticket 45f74cff (users could open any meeting): /l8/meeting/:id had a team/attendee/creator gate for months, but the followups page, all per-meeting read APIs (transcript, exports, recordings, agenda, headlines, share, SSE), and the meeting-captures panel only checked the 'restricted' flag — the gate existed but was never shared.
Scope: agent:Conatus
Adding a scheduled command to run-claude.sh takes THREE edits, not one: (1) APPROVED_COMMANDS, (2) a BUDGET case entry, (3) a RESOLVED_PROMPT case entry that expands the slash command into a literal instruction. The file contains SEVERAL separate `case "$PROMPT" in` blocks, so never anchor an insert on a command name alone; anchor on something unique to the target block (for the resolver, the line `RESOLVED_PROMPT="$PROMPT"` immediately above its `case`). Verify by extracting the resolver block and running it with the prompt as input, rather than by reading the diff: after the edit, `/outreach` must echo a real instruction string, and at least two pre-existing commands must still resolve to prove nothing was clobbered. `bash -n` passing proves only syntax, not that the edit landed in the right block.
Why: Both errors share a shape: a change that looks complete because the part you touched is correct, while the part you did not know about is untouched. The whitelist edit read as done because the command appeared in the file. The anchored insert read as done because the diff showed the right text. Neither was checked against behaviour. The saving grace was that run-claude.sh fails LOUD on an unresolved command, writing FATAL to the run log and an entry to alerts.log, so the job did nothing and said so rather than reporting success. That is the pattern worth copying into every scheduled job: an unconfigured job must be distinguishable from an idle one.
Failure mode: I added /outreach to APPROVED_COMMANDS in run-claude.sh and declared the scheduled job ready. It fired at 10:12 on 2026-08-06 and did nothing: approving a command is only half the wiring, and headless bare mode cannot resolve a slash command from the commands directory. Then, fixing it, I anchored the insert on the string ' "/otp-sales")' and landed the resolver inside the BUDGET case statement instead of the RESOLVED_PROMPT one, which would have left the original bug in place while also giving the job no budget entry.
Scope: agent:Sneeze-Sales
human ai boundary conditions
Never silently terminate a session a human is actively using. Warn first and ask: surface a "Do you still need more time?" prompt before the deadline and extend on any response. Reserve automatic closing for sessions that are genuinely abandoned (no participant, no activity), never for ones that are merely long. Timers should clean up abandoned state, not time-box live work.
Why: The auto-end net was built to stop forgotten meetings from lingering open forever. That is a real problem, but elapsed time is the wrong proxy for abandonment: an active meeting and a forgotten one look identical to a clock. Using the clock alone means the safety net's failure mode lands on the people doing the work, and the product loses trust in the exact moment it is being dogfooded.
Failure mode: OTP auto-closed a live leadership meeting at a hardcoded 60-minute deadline with no warning, while people were actively in it. The proposed fix was to make the timer smarter (derive the window from a duration column). Both the original behavior and that fix are wrong: any silent auto-close of a live meeting is a bad experience. David: "this auto close is not a good thing, it has people saying WTF."
Scope: agent:Radar
operational heuristics
A blocker on one item never blocks the others. Work every item that can be worked, exhaust every avenue on the ones that cannot, and only then report. Park the genuinely undecidable ones and keep going. A question for David is fine and should be raised, but it is raised ALONGSIDE completed work, never instead of it. Concretely for any queue-processing run: partition the queue into workable and blocked at the start, finish the workable set completely, and treat "I found a bug in my own tooling" as work to do inside the same run rather than a finding to report at the end of it.
Why: David's scarcest resource is his attention, and a report that says "here is why nothing happened" spends it without buying anything. The blockers were real but they applied to 2 of 12 rows; I let them set the pace for all 12. The deeper error is that stopping felt like diligence: I had genuinely found real problems, so surfacing them felt like the responsible act. It was not, because surfacing a problem is only half the job when the other ten items were sitting there workable the whole time. Exhaust the possible before you escalate the impossible.
Failure mode: The outreach run hit two items needing David's ruling (Powerhouse Gym, Fastest Labs) and a bug of mine blocking three more, and I reported all of it and stopped. Nine rows sat unsent while I wrote a summary. Worse, I had Clay-verified addresses for two of those companies (Annie Long at Senior Helpers, Jennifer Chasteen at Synergy HomeCare) already in hand from the previous session and did not use them. I presented blockers as a reason the batch could not proceed, when they were only reasons those specific items could not proceed.
Scope: agent:Sneeze-Sales
failure patterns
"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.
Scope: agent:Sneeze-Sales
operational heuristics
When David asks for a report he can distribute, assume the audience is internal leadership, not the people being measured. Per-agent performance comparisons belong in that document at full detail with no warning attached. Do not volunteer to redact, soften, or produce a second sanitized version of performance data unless David names an external or team-wide audience. If the audience would genuinely change the content, ask which audience up front before building, never as a caveat appended at the end.
Why: Manager-level reporting exists to name who is converting and who is not. Attaching a sensitivity warning to that treats normal management reporting as a risk, and hands David an extra decision he did not ask for while he is trying to walk into a meeting. It also spends the final impression of the deliverable on a hypothetical instead of the findings. General shape: resolve audience before writing, not after.
Failure mode: Arin built the 14-day call center review Google Doc for David, then closed by flagging the Amanda vs Erica per-caller comparison as sensitive and offering to cut a sanitized second version before distribution. David corrected: the doc is internal and was never going to the call team. Both the hedge and the offer of a redacted variant were wasted.
Scope: agent:Arin
A franchisee client does NOT block the franchisor, and the two are different companies on different domains (powerhousegymbridgeport.com vs powerhousegym.com). David 2026-08-06: "we do powerhouse bridgeport one location not corporate so if this is the corporate location good to go." Reverse the instinct entirely: an existing franchisee relationship is the strongest possible ASSET in a franchisor email, because it is proof delivered rather than claimed. Lead with it. The direction that matters is one-way: never cold-email a franchisee of a brand whose CORPORATE relationship we are mid-conversation with, and never email the specific client location, but corporate remains open and warmer than cold. Check which entity a domain actually belongs to before assuming brand-family contamination.
Why: I generalised one true fact (do not email a client) into a rule that would quietly delete the addressable market. Franchising is precisely the structure where one brand contains many independent buyers, so brand-level blocking is the wrong shape for this ICP. The asymmetry is worth remembering: blocking a franchisor to protect a franchisee costs a six-figure account and protects nothing, because the franchisee is not the one receiving the email. It also throws away the single best proof point available, which is that the brand already works with us somewhere.
Failure mode: I held Powerhouse Gym corporate (powerhousegym.com) as a policy risk because Powerhouse Gym Bridgeport/Stratford is an active Accelo client on that brand, and I proposed hard-blocking any brand domain where an active client sits anywhere in the system. That rule would have blocked every franchisor whose franchisee we already serve, which is most of the best targets we have.
Scope: agent:Sneeze-Sales
Separate first-party link tracking from email-provider tracking. A tokenized link pointing at our own domain (orgtp.com/join/sales/<token>) is fully click-trackable no matter which mailbox sent it -- the prospect's browser hits our server and we stamp it, no provider cooperation needed. Manual personal-mailbox sending costs only the steps BEFORE the click: email opens (no pixel) and bounce/delivery visibility. When someone says "it came from a personal email so we can't track it", check whether the link destination is ours before agreeing.
Why: The two are routinely conflated, and the conflation kills features that would have worked. Here it nearly closed a ticket whose core ask (who clicked, who signed up, success rate) was fully buildable. The residual limitation is real but narrow, and worth stating precisely rather than as a blanket "not trackable": a dead address renders identically to a live address that ignored you, and the mint timestamp is not the send timestamp.
Failure mode: SUCCESS: Claude -- a join-link analytics ticket was nearly dropped on the false premise that sending from a personal mailbox makes clicks untrackable.
Scope: agent:Claude
failure patterns
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.
Scope: agent:Dash
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.
Scope: agent:Dan
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.
Scope: agent:Dan
operational heuristics
Before treating an OTTO queue as work to approve, check three things: (1) autopilot_ai_settings limits, where 0 means nothing is ever generated regardless of autopilot_is_active; (2) whether pending rows actually carry a non-empty recommended_value; (3) is_active as the approval flag, since the API's status field describes the current value's condition (compliant / invalid_length) and the status query param is accepted then silently ignored. When changing autopilot settings, always read-modify-write the complete settings object, because a partial PATCH risks dropping the other issue types.
Why: Counting pending tasks as available SEO wins overstates the work by orders of magnitude and invites a blanket approve. On orgtp only 171 of 779 pending titles were genuine defects; approving all of them would have overwritten 600+ pieces of good human copy with generic AI copy, damaging the pillar pages the Beacon impressions KPI depends on. The zero-limit config also means every client site, including Workout Anytime at 2,233 pages with issues, has an OTTO that has never run.
Failure mode: SUCCESS: Beacon found that OTTO's "pending task" count is not a backlog of approvable SEO fixes. On orgtp.com ~7,100 pending tasks all had an empty recommended_value, because autopilot_ai_settings carried limit 0 for every issue type on all 19 Search Atlas projects. autopilot_is_active reported true the whole time, so the config looked healthy while generating nothing.
Scope: agent:Beacon
agent roles and authority
OTTO owns metadata, including on orgtp.com which we control. Do not re-litigate the inject-vs-source architecture. Em dashes are permitted in page titles and meta descriptions when it serves SEO; the no-em-dash rule applies to human-facing prose, not metadata. Scope Search Atlas work to OTP/orgtp.com only. Do not propose enabling or fixing OTTO on other client sites: David uses Search Atlas as a tool but does not own the SEO department for clients, so client SEO is not his decision to make and should not be routed to him.
Why: Two failure modes avoided. First, an agent that keeps proposing a source-of-truth migration burns David's attention on a decision he has already made. Second, and more costly, routing client SEO findings to David puts him in a lane he does not own; the correct move on discovering something like 18 zeroed client projects or Workout Anytime's 2,233 unprocessed pages is to note it and stop, not to build him a rollout plan he has no seat to approve.
Failure mode: Beacon recommended against letting OTTO own orgtp.com's metadata, arguing that since we control the repo, titles and descriptions belong in source rather than injected client-side by the ACHOO pixel. Beacon also flagged 44 em dashes in live metadata as rule violations needing a cleanup pass, and raised switching on the 18 zeroed client OTTO projects.
Scope: agent:Beacon
Let OTTO choose page titles. Search visibility is OTTO's domain and its judgment wins there, even when the replacement reads flatter than hand-written copy. The Kennedy-discipline rule governs copy a human reads as persuasion (outreach, landing pages, email, client comms); it does not override SEO metadata. Related workflow David uses: a punchy blog title can be written to drive opens in an email, then left for OTTO to replace later, because the email's shelf life is days while the title's SEO value compounds. So a voice-heavy title being overwritten weeks later is the intended lifecycle, not a loss.
Why: Beacon was applying a persuasion-copy rule to a search-surface artifact and would have blocked the entire blog corpus (the largest page group on the site, 408 URLs) from ever being optimized. The deeper error was treating brand voice and SEO as competing for the same slot when they occupy different time horizons: the email captures arousal now, the title compounds in search later.
Failure mode: Beacon recommended preserving the existing Kennedy-voiced blog titles on orgtp.com and blocking OTTO from replacing them, arguing that "The team fails before the agent does..." becoming "COO's Role in Training Teams for AI Agent Collaboration | OTP" trades a distinctive argument for generic keyword copy, and citing the universal Kennedy-discipline rule as grounds.
Scope: agent:Beacon
operational heuristics
When calculating any client credit or make-good on misspent budget, net out the value of what was actually delivered before proposing an amount. Formula: credit = spend under review minus (conversions delivered x a defensible cost per conversion). State which benchmark rate is used and why. Never default to crediting 100% of spend when the spend produced results.
Why: Crediting gross spend overpays the client and understates the work that did land. It also sets a precedent that any misallocation equals a full refund regardless of outcome. Netting delivered value is both fairer to Sneeze It and more defensible to the client, because it shows the math instead of a round apology number.
Failure mode: Drafted a client make-good credit at 100% of the misdirected spend ($2,501.53), treating the entire amount as a total loss. The spend was not a total loss: it delivered 6 real conversions to the client, and the draft gave that value away for free.
Scope: agent:Claude
failure patterns
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).
Scope: agent:Claude
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.
Scope: agent:Claude
operational heuristics
When changing the arity or shape of a function that other code passes a hand-rolled structural stub into, grep every caller for stubs BEFORE trusting typecheck. In otp-platform, registerOtpTools is called with `as unknown as Parameters<typeof registerOtpTools>[0]` in three places (the HTTP route, Ollie's collector in src/services/ollie-tool-registry.ts, and the parity test). That cast makes any missing method invisible to tsc: migrating otp-tools.ts from server.tool() to server.registerTool() typechecked clean while Ollie's collector, which only implemented .tool(), would have collected zero tools and removed every OTP tool from the chat box at runtime. Rule: a structural stub behind an `as unknown as` cast is an untypechecked interface. Treat it like a second implementation and update it in the same PR. Also: when two hand-maintained lists answer the same question (Ollie's WRITE_TOOLS/READ_TOOLS vs the MCP readOnlyHint/destructiveHint annotations), tie them together with a test rather than trusting them to be edited in step; ours had already drifted twice (discover_intelligence POSTs and INSERTs but was classified read, so it ran with no confirm card; sync_rules_to_file only rendered text but was classified write). Finally, verify a guard by breaking the invariant and watching it fail, not just by watching it pass.
Why: The failure mode is invisible to every automated check: tsc passes, all 3232 tests passed before the shim was fixed because the shim's own test used the same stale stub. It only surfaces as "Ollie can suddenly do nothing" in production. The generalizable form is that casts convert compile-time contracts into runtime hopes, and a codebase with structural stubs has as many implementations of an interface as it has stubs.
Failure mode: SUCCESS: Claude migrated all 56 OTP MCP tools to registerTool with directory annotations, and caught a shim that would have silently emptied Ollie's tool registry in prod.
Scope: agent:Claude
Before diagnosing an OTP support ticket, identify the exact surface the reporter was on, then verify the reported cause can even occur in that state. From the 8/7 R3V batch: (1) "reassign meeting to another team" read as a feature request but was a creation bug — the UI offers "No team (personal)" and POST /meetings silently substitutes the Leadership Team; reassignment already worked via PUT /meetings/:id. (2) "Ask Ollie can't file a ticket" was mistaken identity — OTP has TWO assistants: Ask AI (corpus-only, no tools, so its refusal was truthful) and /ollie-chat (full MCP registry, has submit_ticket). (3) A plausible cause for a typing-freeze was ruled out by a precondition check: the transcribing banner only renders when the meeting has NO transcript, and the reporter had already generated Ollie insights. Say so honestly rather than shipping a fix under a false claim.
Why: Users describe symptoms, not causes. A plausible cause that survives no precondition check produces a fix that fixes nothing while closing the ticket. Checking which surface and which state rules candidates in and out cheaply, and turns "feature request" into "bug" often enough to change what actually gets built.
Failure mode: SUCCESS: Claude — three of six R3V support tickets had root causes different from what their titles said, and only reading the actual surface found them
Scope: agent:Claude
Before designing any adoption, activation or gamification feature, query production for the funnel FIRST and count organizations rather than events. Then look for the outcome the product already produces and is only labelling wrong. Three rules that fell out and should be reused: (1) make progress steps OBSERVED FACTS re-checked on every render, never stored completion flags, so a step goes back down when its fact stops being true (a revoked key must not leave a badge behind); (2) start the ladder with a rung the user has ALREADY cleared (endowed progress) rather than at zero; (3) report the biggest ABSOLUTE drop, not the smallest number, because they are different steps -- here the largest loss was 49 orgs at signup-to-first-meeting, not the agent gap being investigated.
Why: A metric that requires a hand-written query gets checked once and then never again, which is exactly how a zero on the company's core thesis survived for months next to a dashboard that looked healthy. Counting events instead of organizations is the specific trap. And honesty is load-bearing on any adoption surface: the moment a number flatters, the whole surface is worth less than showing nothing, so no points, no badges, no streaks, and zero must render as zero.
Failure mode: SUCCESS: Claude found OTP's north-star metric was zero and nobody knew, by querying production before designing anything. 61 orgs, 12 ran a meeting, 1 ever created an agent seat, 0 agents ever called OTP. The reason it hid: /admin/usage counts ACTIONS (looks healthy, a few orgs run many meetings) while the thesis needs per-ORGANIZATION counting. The fix reused data we already had: Ollie's real work was already recorded per org in wallet_ledger.metadata->>'feature' and had only ever been rendered as billing. Read as a timesheet, the same rows prove an agent already works there.
Scope: agent:Claude
Before presenting a Dash blind-spot, billing trigger, or any state-file alert as a current action item, confirm it hasn't already been resolved. Stale state files (Dash May 25 was ~5 weeks old) carry point-in-time alerts that may be closed by now. Trust confirmed/observed status over stale notes; flag the data's age and treat unverified alerts as 'verify' not 'urgent.'
Why: Re-surfacing already-resolved alerts as urgent erodes trust in the L10 briefing and spends David's attention during a low-push recovery window. Honesty about data staleness matters more than appearing comprehensive.
Failure mode: Dan surfaced the HiTone billing trigger ($43-49K/mo possibly un-invoiced) from Dash's stale May 25 state file as a live concern during the Jun 29 L10. David confirmed HiTone billing is correct and already handled, and asked to close it out.
Scope: agent:Dan
KPIs/scorecards must live as tiles in OTP (the source of truth), not in markdown files or meeting briefs. Every active agent/human seat — including Dan's strategic co-founder seat — must own at least one OTP KPI tile. When proposing measurables, verify against list_my_kpis and create the missing tiles via update_kpi (auto-creates), rather than just tabling them in a doc. A seat with no number is sitting on the sidelines.
Why: EOS requires every seat to have a measurable. Discussing KPIs in a brief while OTP shows none of them makes the scorecard fiction and undercuts OTP as the coordination source of truth. Dan as co-founder must be measurable like everyone else.
Failure mode: Dan presented a Sneeze It agent-team scorecard as a markdown table in the L10 brief and treated it as 'the scorecard,' when the source of truth is OTP. David caught that Dan (and Arin/Pulse/Dirk) have NO KPI tiles in OTP at all — Dan's own seat had zero measurables. A scorecard that only lives in a file or meeting brief does not exist.
Scope: agent:Dan
failure patterns
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.
Scope: agent:Tally
operational heuristics
An OTP KPI with teamId=NULL renders only on /dashboard/kpis, never on any L10 scorecard (meeting scorecards filter strictly by meeting.team_id). To make a KPI show on a specific L10, PATCH /api/v1/kpis/:id with the meeting's teamId. The 'Dan L10' meetings run on the 'ai-army' team (065d1d4b-c7da-4e80-b3ed-d6b101471d2c). Tally's auto-create now includes teamId from a 'team_id' field in the registry entry, so new agent-army KPIs land on the Dan L10 automatically instead of orphaned. Find team IDs via GET /api/v1/teams; meeting->team via GET /api/v1/meetings.
Why: A KPI nobody can see on their meeting scorecard is functionally not on the scorecard. The owner/title is necessary but not sufficient — team scoping is what makes it report. This is a recurring gotcha for any agent creating KPIs via the API.
Failure mode: SUCCESS: Tally — agent KPIs were invisible on the L10 because auto-create left teamId NULL. David flagged that the new KPIs weren't reporting on the Dan L10 or /dashboard/kpis as expected.
Scope: agent:Tally
failure patterns
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.
Scope: agent:Conatus
operational heuristics
Root cause was the SearchAtlas OTTO pixel having an EMPTY src="" in the layout head (v7.ejs, onboarding.ejs, main.ejs). The OTTO tag must carry its base64 data-URI loader in src that appends dynamic_optimization.js with data-uuid; with src="" the runtime never loads, so OTTO injects/verifies nothing. When an OTTO/SearchAtlas audit reports 0/N across ALL on-page categories, suspect the pixel loader, not the actual tags — verify the sa-dynamic-optimization script's src is populated, not the page's own meta.
Why: A 0/16 across every category despite visibly correct meta is the signature of a non-loading optimization runtime, not missing tags. Checking the pixel first avoids a pointless rewrite of titles/descriptions that were never the problem.
Failure mode: SUCCESS: Beacon/SEO — orgtp.com OTTO on-page audit showed 0/16 (titles, meta descriptions, headings, meta keywords all failing) even though pages had perfectly good title tags and meta descriptions server-side.
Scope: agent:Beacon
A brand battle cry needs a genuinely designed moment (confident display type, intentional line breaks, brand device, real whitespace), not a centered text block plopped in. And the VISIBLE battle cry copy is the short clause only: 'Unlocking the potential in every person through the partnership of people and AI' — drop 'so together we leave the world better than we found it' from the hero display (keep the full sentence only for formal/footer contexts).
Why: A mission line is a brand centerpiece. Long copy dilutes the punch, and an undesigned drop-in reads as filler. The payoff phrase 'partnership of people and AI' must land as the climax with design weight behind it.
Failure mode: Adding the OTP mission as a 'battle cry' on the landing page, I dropped the full sentence into a plain centered text band wedged between hero and Step 1. David called it 'a weak attempt to just throw it on the page' and said the full line is too long for the visible battle cry.
Scope: agent:Conatus
Manifesto/mission pages must be written as movement recruitment, not product marketing: second-person address (the reader is the protagonist), "We believe" creed statements people can recite, a named enemy, stakes, and invitation CTAs ("Join the movement") instead of transactional ones ("Start free"). Product features appear only once, framed as how the movement fights, not what the product includes.
Why: People join movements because they believe what the movement believes (Sinek: start with why). Copy that sells the what on a page whose job is to recruit believers reads as generic SaaS and inspires no one, no matter how good the design is.
Failure mode: Redesigned the orgtp.com manifesto homepage with strong visual design but kept product-brochure copy (feature lists, "free meeting software", "Start free" CTAs). David: "the writing does not inspire an army of followers... this just looks the same as every other company... blah."
Scope: agent:Claude
Judge conversion on the full path the visitor actually walks (page, door, day-one experience), not on the surface being edited. If the honest answer to "would you sign up" is "yes IF another surface delivers," the answer is no, and the work moves to that surface. Never write a promise on a button that the destination page cannot cash.
Why: Trust destroyed at the moment of verification is unrecoverable; a skeptical buyer who clicks "watch us run" and lands on a data page is gone forever. Copy that outruns proof is hype by definition, and the exact audience OTP needs (operators) is the audience that punishes it hardest.
Failure mode: After rewriting the OTP homepage, I declared the copy converts because skeptics would "click through to the live OOS page and sign up IF it delivers." David called it: I kicked the can to a page I know does not deliver, and called it a win. The button promises "Watch our company run, live" but the OOS page it links to is a list of published rules, not a running company.
Scope: agent:Claude
OTP's enemy statement is "you bought the operating system and the needle didn't move." The pitch is not better meetings; it is: the system was fine, what was missing was the workforce that runs it between the meetings. Frame all homepage/sales copy against needle-not-moving, not against meetings.
Why: This is the buyer's actual lived disappointment (paid for an operating system, company looks the same two years later) and it positions OTP against incumbents on outcomes instead of features.
Failure mode: The letter's hero framed the enemy as "the meeting" / busywork. David corrected the thesis: the real problem is that companies bought operating systems and software (Ninety, Bloom Growth, etc.) that did not move the needle. Years later the company had not grown and was not better, and they needed to change how they did things.
Scope: agent:Claude
core operating rules
The calendar no-modify/no-delete rule applies to CLOSED (past) events only — the historical record is immutable. Future/upcoming events CAN be updated or cancelled when David explicitly directs it. Still confirm scope once before outward-facing changes (cancellations notify attendees).
Why: Past events are the billable-time and time-allocation audit trail, which is what the rule protects. Blocking directed changes to future events just adds friction and makes David do the work himself.
Failure mode: Dan blocked a David-directed calendar change (removing Janine from the recurring 1:1 series) by over-applying the "NO DELETE. NO UPDATE. Never modify existing events" calendar rule to all events.
Scope: agent:Dan
operational heuristics
For any UI change, design from the user's mental model, not the data model: "my list shows my work; work I assigned to others shows under Waiting on Others." When a meeting todo is assigned to someone else, stamp the creator as delegator so it routes to the delegation view. Before shipping UI changes, run the UX lens (impeccable / web-design-guidelines skills + src/DESIGN.md), not just a minimal code patch.
Why: A technically-correct patch that ignores the user's mental model just moves the confusion. OTP's own product language already has the right home for these items (Waiting on Others); fixes should land in the model the user already understands.
Failure mode: Fixed the dashboard todo confusion (teammates' meeting todos looked like the viewer's own) by adding an owner label to the rows. David corrected: that's not thinking like a user. Labeled-or-not, other people's todos don't belong in "my to-dos" at all.
Scope: agent:Dan
When David asks for a jaw-drop brand page, build an EXPERIENCE, not an article: full-viewport cinematic hero, scroll choreography, one idea per screen at massive scale, motifs that live in the page as motion, ruthless copy cuts, no standard nav/footer chrome breaking the spell, no section-grammar scaffolding, no FAQ accordion bolted onto a manifesto.
Why: The gap between "well-executed page" and "omg I love this" is the whole assignment on brand surfaces. Safe editorial structure is invisible at best; for-the-brave positioning demands the page itself be brave.
Failure mode: Built the /ollie manifesto page as a competent editorial layout (repeated mono eyebrow labels on every section, index rows, alternating light/dark sections, FAQ accordion at the bottom) and David rejected it outright: "this really really sucks." The brief was "reader drops on the ground saying omg I fucking love this" and the output was a safe template that reads as AI scaffolding.
Scope: agent:Claude
When David gives a design reference URL, open it in a browser and STUDY it visually (proportions, type sizes, spacing, alignment) before designing; match its register, not just its layout skeleton. Elegant means restrained: modest type scale, centered calm hierarchy, generous whitespace, thin rules. Never hand-draw SVG artwork to imitate produced brand art; crop/reuse the actual asset or use nothing.
Why: A reference URL is the brief. Reading its HTML structure without seeing it rendered led to importing the skeleton with the wrong soul, twice. Amateur freehand art next to professional motion work destroys credibility instantly.
Failure mode: Second rejection on the /ollie page. David asked for sakana.ai/fugu: elegant, Japanese sense of design (restraint, whitespace, calm, modest type, precision). I delivered giant 9vw headlines, one shouting line per viewport, and hand-drawn SVG chevron "birds" that rendered as crude fat marker scribbles. I treated "jaw-drop" as scale and boldness when the reference was quietness and precision, and I drew freehand SVG art instead of using the actual video's artwork.
Scope: agent:Claude
For fleet-wide spec maintenance: (1) tarball backup of ~/.claude before any agent touches specs; (2) partition files into DISJOINT clusters, one agent each, with CLAUDE.md owned by exactly one; (3) give every auditor the same stale-fact canon and the rule "verify a launchd plist exists before believing any schedule claim"; (4) auditors apply surgical edits directly for factual fixes but RETURN structural proposals for David instead of applying them; (5) synthesizer closes cross-cluster contradictions the auditors flag at each other.
Why: Agent specs rot faster than anyone audits them: this pass found live specs for a retired agent (jeff.md ending in "Go."), four phantom schedules, Todoist writes in five files, terminated employees still routed DMs, and a Bassim score-inflation bug. Periodic fleet audits with disjoint ownership are cheap insurance against agents acting on dead infrastructure.
Failure mode: SUCCESS: Claude ran a five-cluster parallel level-up of the entire agent army (80 files, ~140 surgical edits) without a single file conflict or lost spec.
Scope: agent:Claude
Pattern for UX dead-end hunts: (1) fan out parallel read-only explorers per surface (meetings, teams/members, KPIs/todos, onboarding/settings) asking for file:line + user-visible symptom + minimal fix; (2) fix the unsatisfiable states first: any required dropdown that can render zero options must explain where its options come from and link there (owners/attendees come from the org chart, meeting membership from teams); (3) empty states must branch on WHY they are empty (org has no teams vs user not on a team need different CTAs); (4) never report an async side effect as done: invite emails now await sendEmail (which returns null on failure, never throws) and return emailSent so the UI can tell the truth; (5) a guided setup checklist computed server-side from actual data (seats/team/KPI/meeting/members exist?) beats static onboarding because it survives skipped onboarding.
Why: These are the recurring shapes of broken UX in OTP: forms with prerequisites the user cannot see, empty states that misdiagnose their cause, and optimistic success messages over fire-and-forget side effects. Fixing the shape, not just the instance, is what makes the product feel intuitive.
Failure mode: SUCCESS: Claude ran a full UX dead-end audit and fix pass across OTP (4 PRs, #113-#116, all deployed)
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
operational heuristics
The sweep pattern that worked: audit by rule-cluster in parallel (fakery, insight-to-agency, jargon/states, first-meeting goal-walk), then execute severity-first. Key catches to re-check every run: (1) seeded/synthetic data leaking into numbers a reader believes are real (the is_template flag existed but was never enforced; counts now use src/shared/synthetic-orgs.ts); (2) the conversion moment must be ON the default path (end-meeting now lands on Ollie followups, not the list); (3) funnels don't exist until instrumented (insight topic: surfaced/accepted/value_delivered); (4) credentials in seed script comments (one prod DATABASE_URL scrubbed; password rotation still owed). Worklist for run 2 in otp-platform/mission-standard/WORKLIST.md.
Why: The Mission Standard is a repeatable bar, not a one-off audit. Recording the found failure classes makes run 2 start from run 1's ceiling instead of re-discovering it.
Failure mode: SUCCESS: Claude ran Mission Standard sweep run 1 (PRs #121-#123, deployed): 4 parallel rule-audits over OTP, 5 CRITICAL + 13 GAP found, all CRITICAL and 9 GAP closed same-session
Scope: agent:Claude
failure patterns
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.
Scope: agent:Claude
core operating rules
Keep the David+Dan L10 strictly Sneeze It agency focused. Segue and headlines pull from agency wins (client billing, ad performance, call center, team) plus a genuine PERSONAL good-news item. Never surface OTP product work here — it lives in OTP's own meeting.
Why: The two companies were deliberately split into two meetings on 2026-06-08 because carrying both made the L10 messy. Dan is Sneeze It only. Mixing OTP back in re-creates the exact problem the split solved.
Failure mode: Dan's L10 Segue for the David+Dan meeting led with OTP wins (Ollie landing page, EOS trademark remediation) even though this L10 is Sneeze It agency ONLY since the 2026-06-08 company split. OTP has its own L8/Delta Meeting.
Scope: agent:Dan
operational heuristics
When an agent-pushed OTP to-do references a document, include a clickable https link (a Google Doc), not a local file/vault path — David reviews to-dos on mobile. To update an existing to-do's description, use PUT /api/v1/todos/:id (not PATCH). otp-todo.sh has no update verb, so PUT directly with the API key.
Why: A file path in a to-do is dead weight on mobile — the reviewer can see the reference but cannot open it, which reads as "the link is missing/broken." Every agent that pushes doc-linked to-dos (Radar, Pepper, Dan) hits this.
Failure mode: Dan pushed an OTP to-do referencing a document but put a local Obsidian vault path ("2nd Brain/Agent Army/Dan/...") in the description. David opens to-dos on his phone — a vault path is not tappable, so there was "no link to click." Also used PATCH to update the to-do; the OTP todos API update method is PUT /api/v1/todos/:id (PATCH hits the marketing site and returns HTML).
Scope: agent:Dan
To put an agent-army/IDS issue on an OTP meeting board, POST /api/v1/tickets with the team's teamId (category 'other' for strategic issues, priority low/medium/high/critical, ownerEntityType+ownerExternalId). The MCP submit_ticket tool CANNOT do this — it has no teamId param (it is the generic 'report a bug to OTP' path), which is why nobody ever got issues onto the board. Team IDs: 'AI Army' = 065d1d4b-c7da-4e80-b3ed-d6b101471d2c (the David+Dan agent-army meeting); Leadership Team = c1e1a485-414e-48d5-ae44-e81bd110b554. Update/solve via PUT /api/v1/tickets/:id (idsStatus, priorityRank, resolution).
Why: Agents could push KPIs and todos to OTP but not issues, so every L10 IDS board rendered empty and David kept discovering the hole live. Issues=tickets + teamId scoping is the missing piece; without it a meeting-readiness check would keep mislabeling a working API as absent.
Failure mode: Dan claimed 'no issues API exists in OTP' because there is no src/routes/api/issues.ts. That was wrong. OTP stores IDS issues in the TICKETS table (schema.ts: 'issues live in the tickets table'), with full IDS support (idsStatus, priorityRank, teamId, owner fields). The agent-army IDS board was empty only because our issues lived in a local markdown file and were never pushed as tickets scoped to a team.
Scope: agent:Dan
OTP has TWO distinct features both called "Ollie Insight": (A) the per-meeting followups wizard that turns a transcript into meetings.ai_summary (src/shared/meeting-followups.ts, transcript-only), and (B) the reusable "address engine" (ollie_insights table, src/services/ollie-insight.ts + shared/ollie-insight.ts + partials/ollie-insight-block.ejs) that gathers org data (KPIs/rocks/todos/meeting-summaries) per SCOPE. When David said "KPIs shouldn't be in the meeting analysis," the fix was in System B's meeting-scope evidence gathering, NOT System A. The block partial (ollie-insight-block.ejs) is fully scope-generic (builds the API URL from data-oib-scope/scopeId client-side), so adding a brand-new 'quarter' scope end-to-end took only: add to INSIGHT_SCOPES + RULES_BY_SCOPE (shared), the scopeQuerySchema enum + a resolveInsightScope branch (api), a gatherEvidence branch + max_tokens (service) -- then just include the existing partial with scope:'quarter'. No new render/generate/receipts UI. Pattern: when a feature is "one engine, many surfaces," new surfaces are a scope + evidence branch, never new UI.
Why: The name collision hides which code to touch; picking the wrong system wastes a whole edit pass. And recognizing the scope-generic block means big-feeling asks ("a quarterly synthesis button") are small, low-risk diffs. Both are recurring shapes in OTP's Ollie work.
Failure mode: SUCCESS: Claude separated OTP's two "Ollie Insight" systems and added a whole new scope by reuse
Scope: agent:Claude
New endpoint POST /api/v1/meetings/:id/agent-record (PR #154): an agent submits the written meeting record; OTP runs the same redaction ruleset, stores it to meetings.transcript, logs an audit baseline + agent_record event, and the existing /ai/followups generate turns it into to-dos/issues/headlines/insight unchanged. Agent path: ~/.claude/otp-meeting.sh record <meetingId> --file=<record> --source=l10dan. Wired into /l10dan conclude step 6. Verify deploy by probing the endpoint returns JSON not the marketing SPA HTML before pushing records.
Why: Ollie only reads transcripts, so agent-run meetings had no way into it — the empty-insight hole David hit live. This closes it: any agent meeting can now produce Ollie follow-ups. Also a general UI rule captured: buttons reflect capability/state (action taken -> button disappears).
Failure mode: SUCCESS: Dan shipped the Ollie agent-record path so agent-facilitated meetings (David + AI L10, no audio transcript) can feed Ollie Insights.
Scope: agent:Dan
Before treating a KPI/data source as blocked, re-read the LIVE source, not the note about it. The Havok "non-client %" KPI was marked blocked for ~14 weeks on "the timesheet has no client column" — a 105-day-old memory. The live sheet (1VPlH5ZqTowOe2nJDFwOjuvXrCXUnOzeOb3aO-M936Xo) had since grown per-person tabs with a full Client/Time/Date schema; one read unblocked it. Pattern for wiring a messy sheet into Tally: (1) get_spreadsheet_info to list tabs, (2) read a person tab to learn the real schema, (3) confirm recency by reading the tail (last date), (4) add a focused extract mode to tally.py rather than reshaping the sheet — here `client_attribution_since` (reads ALL valueRanges via a new _values_2d_all, parses h:mm via _parse_hhmm, added dotted DD.MM.YYYY to _parse_date, classifies internal by an `internal_contains` substring), (5) `tally.py --dry-run --kpi "<title>"` to prove the number off live data before pushing. Human-owner + a 1:1-team KPI (owner HUM_BOGDANTABAKA, team = David-Bogdan 1:1) pushes fine via find_or_create_kpi.
Why: Blocked-status notes rot silently while the underlying source improves; a KPI can sit "pending" for a quarter when it was buildable weeks ago. Re-reading the live source first is the cheap unblock. And the tally.py extract-mode pattern makes any timesheet/sheet a live KPI without asking a human to restructure their doc.
Failure mode: SUCCESS: Dan/Tally shipped the Havok client-attribution KPI live in one session after a 14-week "blocked" note turned out stale
Scope: agent:Dan
core operating rules
Treat OTP as an Organizational Knowledge Engine, not meeting software. Meetings are ONE input among equals: conversations, voice notes, documents, emails, chat, SOPs, decisions, KPIs, customer interactions — all feed one continuously evolving organizational understanding. Ollie is the organization's memory (not a meeting assistant): attends meetings, processes recordings + uploaded docs, reads SOPs, understands relationships, connects historical decisions, answers using org context. Every feature must pass ONE test: 'Does this increase shared organizational knowledge?' Yes -> keep; No -> question why it exists. Never ask 'How do we recreate EOS?' — always ask 'How do we create shared knowledge that naturally produces shared purpose?' The input does not matter; the shared understanding does. Companion onboarding decision: stop teaching users 'how to create a meeting' — in the first meeting the user inputs the structure themselves (expanded separately in OTP Lab).
Why: This is the north-star reframe (committed by David 2026-07-08). It changes what every agent builds, writes, and ships for OTP: external positioning (knowledge engine, not meeting tool), Ollie's role (org memory across all inputs), the feature filter (the one test), and onboarding (user brings the structure). Vision = Where People and AI Work as One; Mechanism = Shared Knowledge, Shared Purpose.
Failure mode: SUCCESS: OTP product realization — we were building OTP as meeting software (solving the HOW: recreate EOS / run a better meeting) when the actual purpose is to create Shared Knowledge -> Shared Purpose so people and AI work as one organization (the WHY). Meeting-first framing was the wrong altitude.
Scope: agent:Dan
operational heuristics
Before editing any OTP view to fix an on-screen bug, grep unique visible strings from the screenshot (e.g. "WAITING ON OTHERS", "always only yours") across src/views to confirm WHICH template renders that exact surface. Multiple pages can render similar-looking todo lists (me-todos.ejs vs dashboard-daily.ejs). Verify the rendering route (reply.view target) too.
Why: Two round-trips and two merged PRs produced zero visible change because the edits were on the wrong template, which read as "nothing is fixed" and eroded trust. A 10-second grep on the screenshot text would have pointed to the right file immediately.
Failure mode: Fixing an OTP todos UI bug, I edited src/views/pages/me-todos.ejs twice and shipped two PRs, but the surface David actually uses is the dashboard-daily "Waiting on others" widget (src/views/pages/dashboard-daily.ejs). Nothing he saw changed.
Scope: agent:Conatus
Two reusable patterns: (1) Before building any OTP email/engagement feature, grep src/services for existing infrastructure -- re-engagement.ts, lifecycle-scheduler.ts, and user_engagement_log already carried cadence caps, suppression, logging, and a daily cron, so the todo-aware upgrade was ~350 lines instead of a new subsystem. (2) When resolving "which org does this Clerk user belong to", organizations.clerkOrgId only knows the org CREATOR; invited teammates must be resolved through org_members.clerkUserId + claimedEntityIds. This gap is why per-user personalization (open todos) missed non-creator members like Nate.
Why: One engagement channel with shared caps is what keeps daily utilization pressure from becoming annoying double-mailing, and the creator-vs-member resolution gap will bite any future per-user feature (digests, notifications, billing seats) that starts from organizations.clerkOrgId.
Failure mode: SUCCESS: Claude shipped the smart engagement email engine (PR #186) by upgrading the existing re-engagement service instead of building a parallel system
Scope: agent:Claude
failure patterns
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.
Scope: agent:Arin
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.
Scope: agent:Arin
operational heuristics
Frame help/success/onboarding call copy positively: state plainly that the call is there to help and guide them, and describe what will actually happen on it (we'll walk through your setup, get you unstuck, answer your questions). Never say "this is not a sales call" or "no pitch" — describe the help, don't disclaim the sell.
Why: Defensive "not a sales" language triggers the exact suspicion it tries to defuse and undercuts a genuine help offer. David flagged this immediately.
Failure mode: Wrote a "customer success call" Calendly description that leaned on "no pitch, no slides" / not-a-sales-call framing. Protesting that it isn't a sales call makes it sound like one.
Scope: agent:Conatus
Any script that answers "is anything missing / is everything covered?" must fail LOUD, never return an empty set as reassurance. Two rules: (1) assert the expected top-level key exists (`if 'data' not in resp: raise`) before computing a result; a zero/empty answer from a health check is a claim that must be proven, not a default. (2) Cross-check a zero result against one known-positive case before reporting it -- here, one direct call to a single account would have shown $57 of spend and exposed the lie instantly. Also: `~/.claude/meta-ads.sh accounts` exits 0 and prints nothing; do not build on it. Sweep with `/{business_id}/adaccounts?fields=name,account_status&limit=500` then batch `/{act_id}/insights` 50 at a time.
Why: "Nothing is wrong" is the single most dangerous output an audit can produce, because nobody investigates it. A silent empty result on a billing sweep means real revenue is never invoiced and nobody ever finds out. The failure mode is not a crash, it is confident silence.
Failure mode: SUCCESS: Claude caught a silent false-negative in a Meta billing sweep. Querying the Graph API adaccounts edge with nested field expansion (`fields=name,insights.date_preset(this_month){spend}`) returned an error payload with NO `data` key. The sweep script read it as an empty account list and confidently reported "0 accounts, $0.00 unbilled MTD spend" -- a clean bill of health that was entirely fabricated. Direct per-account queries then revealed 7 unbilled accounts spending $5,673 MTD.
Scope: agent:Dash
Compare with Another OOS
Search for an organization to compare against.