ADR-0010: Extract the attachment parser into its own Lambda
- Status: Reverted 2026-04-27. The scatter-gather architecture shipped in Phase 8 introduced more failure surface than it removed at current traffic. Returning to inline parsing in Clara. The reversal rationale is captured below; the original decision text is preserved for history.
- Date proposed: 2026-04-17
- Date accepted: 2026-04-26
- Date reverted: 2026-04-27
- Supersedes: none
- Related: ADR-0001 (Lambda / Vercel split), ADR-0002 (agents own their
code — duplicate, don't re-export), ADR-0004 (per-Lambda PR preview
deploys), Phase 4 (Clara runtime extracted to
agents/clara/).
Reversal — 2026-04-27
Decision: Revert Phase 8. Bundle the rent-roll / PDF / XLSX / PAR parsers back into Clara's Lambda. Parse inline. Delete the attachment-parser Lambda and the orchestrator scaffolding.
Why we reverted, in order of severity:
Five distinct production incidents in one day, all rooted in the scatter-gather coordination layer:
- Phase 8.4 cutover guard tripped on a Yale email because env vars were stripped without scatter being engaged (the original "Attachment parsers unavailable" failure that motivated this whole thread).
- The deploy script wiped env vars on every update (preserved-on-update fix landed in PR #431 mid-incident).
- Lambda version snapshot drift:
:livealias pointed at v180 which had no orchestrator env vars, while$LATESThad them. The prod-promote workflow froze the wrong config into the version. - Phase 8.3 (real-traffic handler) was opened as PR #431 on 2026-04-26 but never merged. Phase 8.2 (skeleton that throws on real traffic) ran in prod for ~24 h with scatter routing live, sending every attachment to a Lambda that immediately failed.
- The DDB-counter / S3-PutObject race (
Promise.allof decrement + write) — caught in /simplify on 2026-04-27, but the fact that this was a real heisenbug shows the coordination layer is bug-prone, not bug-free.
Zero functional gain. The original motivation was Lambda cold-start latency and bundle size for the conversational-email common case (95%+ of traffic). At current volume (~10 emails/day, single-tenant property roll-out), Clara's cold start is sub-second already; the ~3 MB bundle delta from inlining the parsers is invisible against per-invoke variance. We paid real failure-mode cost for a theoretical performance win we cannot measure.
Operator burden. Three env vars × two Lambdas × two version-pinned aliases = many places where a single config drift breaks the data pipeline silently. Each incident in (1) was a different shape of the same problem: state distributed across systems that didn't need to be.
The simpler shape works. Pre-Phase-8 Clara had
processInboundEmailcallprocessAllReportAttachmentsinline — one function, one Lambda, one failure mode (parser threw). It fit on one screen. It is the shape we are returning to.
What stays from Phase 8 work:
- The Yardi
RentRollwithLeaseChargesmulti-row aggregation fix from PR #462 lives insrc/lib/rent-roll/parsers/generic-csv.tsand is reused by the inline path on the way back. - The
parsedNonZeroguard inapply-entity-sync.tsfrom PR #462 stays — independent of the parser-Lambda question. - Camellia's data-API skip (
PROPERTIES_USING_DATA_APIset) stays — it is a property-routing rule, not a parser-architecture concern.
When this could come back. If we hit ≥100 emails/minute sustained and Clara's cold start regresses past ~5 s and the parsers specifically (not the agent loop) are the bottleneck, an out-of-band parser is the right reach. We will know the numbers when we see them; until then, inlining is the default.
Lessons recorded for future architecture decisions:
- Suggest the simpler shape first, then evaluate when complexity pays for itself at current traffic, not hypothetical future traffic.
- Coordinator / counter / scatter-gather patterns add multiplicative failure surface (env-var drift × version snapshot × multi-Lambda alias × DDB race × DLQ stragglers); each multiplier is its own incident shape.
- "Phase 8.x" rollouts where Phase N depends on operator-driven Phase N+1 env-var setup are a smell — the cutover gap is a window where prod is half-configured and the failure surface is at its widest.
The original decision text follows for history.
Context
When a prospect or PM sends an email with a data attachment, Clara's Lambda
(propflow-inbound-processor) currently parses the attachment inline
on the same compute that is running the leasing/maintenance agent loop:
- SES → SQS → Lambda handler receives the SES message.
- The handler fetches the raw email from S3.
processInboundEmailrunssimpleParser, uploads attachments to S3, then on the SAME invocation runs every applicable parser:parsePARReport(Claude Vision on the PDF — ~50 seconds)parseWeeklyReport(XLSX → structured JSON)detectAndParse(rent-roll detect + LLM fallback)parsePdfWithVision(rent-roll PDF fallback — another Vision call)
- Only after all parsing finishes does the agent loop run against the extracted content — or against the plain body text if it's a conversational email.
This design has a few problems:
1. Latency coupling
A conversational email (common case, 95%+ of traffic) pays nothing for attachment parsing — but the Lambda still bundles all the parser code and its dependencies. Cold-start penalty for everyone.
2. Bundle bloat
The inline parsers pull in pdf-parse, xlsx, papaparse, plus the full
@/lib/rent-roll/** and @/lib/property-reports/** trees. These add
multiple megabytes to Clara's Lambda bundle, which already bumped up against
the 50 MB zipped limit during the Phase 4 extraction.
3. Timeout risk
Vision-based parsing takes 30-60 seconds per PDF. An email with two PDFs can run for two minutes in Clara's Lambda — but the handler has to also do agent work, send a reply, and flush traces before the 15-minute hard cap. The failure mode is ugly: Lambda timeout kills an in-flight parse, the SQS message goes back to the queue, and the next attempt re-parses everything from scratch.
4. Retry semantics are wrong
Parsing is idempotent (same PDF → same JSON) and benefits from aggressive retries. Agent work is not idempotent (sending a reply twice is bad) and needs careful dedup. Bundling them into one handler means we tune SQS visibility + retry policy for the worse-behaved half.
5. Observability is muddled
When a report parse fails, the failure surfaces as a generic Clara error because there's no dedicated span or CloudWatch log group. Operators can't tell at a glance whether the rent-roll parser is unhealthy.
Decision
Extract attachment parsing into a dedicated Lambda — agents/attachment-parser
— and wire Clara to it via a scatter-gather pattern over SQS:
┌──────────────┐ email ┌──────────────────────┐
│ SES → SQS │───────────────────► │ Clara Lambda │
└──────────────┘ │ (propflow-inbound) │
└──────────┬───────────┘
│
1. raw email → S3 │
2. publish parse task │ (per attachment)
▼
┌──────────────────────┐
│ attachment-parse │
│ SQS queue │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ attachment-parser │
│ Lambda (fan-out) │
└──────────┬───────────┘
│
parsedAttachments → S3
│
▼
┌──────────────────────┐
│ inbound SQS (back) │
│ email-enriched msg │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Clara Lambda │
│ (agent loop runs) │
└──────────────────────┘
Concretely:
agents/attachment-parser/is a new subproject, structured likeagents/clara/(package.json, tsconfig.json, handler.ts, build.ts, README.md, fixtures/, lib/). Parsers inagents/attachment-parser/lib/parsers/are COPIES of the src/lib versions per ADR-0002 — not re-exports. The clara-fallback plugin (lambda/lib/esbuild-clara-fallback.ts) handles transitive@/lib/*resolution during the transition period.The parser Lambda accepts
ParseTaskevents (one per attachment), reads the S3 key, dispatches to the right parser (PDF / XLSX / CSV / rent-roll / PAR / quote), writes aParseResultback to S3 underparsed-attachments/<recordId>/<attachment-index>.json, then publishes a singleemail-enrichedSQS message to the inbound queue when the last attachment is done.Clara gains a new
email-enrichedchannel. Its handler feeds the pre- parsedparsedAttachmentsarray intoprocessEmailRecordso the agent sees extracted content without touching a parser itself.The original
emailchannel keeps its current inline-parsing code path, gated byisAttachmentOrchestratorEnabled():- If the three env vars
SQS_ATTACHMENT_PARSE_QUEUE_URL,SQS_INBOUND_QUEUE_URL, andS3_ATTACHMENT_BUCKETare set, publish parse tasks and return early (the enriched message comes back later). - Otherwise, fall through to inline parsing (status quo).
This gives us a zero-risk rollout: the code change can merge and deploy before the infrastructure exists, and production traffic keeps using the old code path until we flip the env vars.
- If the three env vars
Once the cutover is complete and stable, we remove pdf-parse, xlsx, and papaparse from Clara's bundle entirely (they're already externalised from some Lambdas per Phase 8.1). Vercel admin routes (
/api/properties/parse-rent-roll,/api/property-reports/parse) can either keep their inline implementation (simpler) or switch to calling the attachment-parser Lambda's Function URL synchronously (smaller Vercel bundle).
Consequences
Positive
- Parser failures don't starve Clara's retry budget and vice versa.
- Clara's bundle drops pdf-parse + xlsx + papaparse + rent-roll/** + property-reports/** — several MB.
- Attachment parsing gets its own CloudWatch log group, metrics, and concurrency limits. Easy to see how many PAR parses are in flight.
- The parser Lambda can be scaled up independently (e.g., reserved concurrency for Vision-heavy parses) without touching Clara.
- Per-parse retry semantics: SQS-level
maxReceiveCount+ visibility timeout tuned for "slow but idempotent" workloads.
Negative / costs
- More infrastructure: one more SQS queue, one more Lambda, one more S3
prefix for parsed results. Cleanup lifecycle rule needed on the
parsed-attachments/prefix. - Slightly higher end-to-end latency on the happy path (one extra SQS hop). Acceptable because the agent loop was already blocked on Vision calls today.
- Scatter-gather requires a "last one out" pattern: a pending counter
in DynamoDB (
emailIngestionRecord.pendingAttachments) that the parser decrements atomically. When it hits zero, the parser publishes theemail-enrichedmessage.
Rollback
The orchestrator is env-var-gated. To roll back: unset any of
SQS_ATTACHMENT_PARSE_QUEUE_URL / SQS_INBOUND_QUEUE_URL /
S3_ATTACHMENT_BUCKET on the Clara Lambda config, and it reverts to
inline parsing without a code deploy.
Migration plan (phases)
Phase 8.2 (this commit): skeleton Lambda + ADR. Lambda builds and smoke-invokes with
_smoke: truefixtures but does not yet receive real traffic.Phase 8.3: Clara gains
attachment-orchestrator.tshelpers +email-enrichedchannel. Still env-var-gated OFF in prod.Phase 8.4 (infra): create the SQS queue + S3 prefix + IAM permissions. Flip env vars on in stage/preview first.
Phase 8.5: flip env vars on in prod. Monitor. Once stable, delete the inline parser imports from Clara's bundle and mark pdf-parse/xlsx/papaparse as
externalin Clara'sbuild.ts.Phase 8.6 (optional): refactor Vercel admin routes to call the attachment-parser Lambda Function URL. Only if the bundle-size savings justify the added latency.
Alternatives considered
In-process parsing with a worker thread. Rejected — still ships all the parser code inside Clara's bundle, still suffers from Lambda timeout coupling.
Call Claude Vision directly from Clara without a separate Lambda. Rejected — doesn't help the bundle-size or retry-semantics problems, and reinvents the SQS retry/DLQ logic at the HTTP level.
Make pdf-parse / xlsx external and let the Lambda runtime provide them via a Layer. Rejected — Lambda Layers add operational burden and still leave the parser code inside Clara's Lambda, so none of the observability or timeout benefits land.