AWS Lambda Cron-Routinen — Fallstricke und Pattern
Verdichtete Lessons-Learned aus der ersten Foundations-Session (2026-05-12). Drei IAM-Bugs auf Live-Failure entdeckt, ein Timezone-Hack erfunden, ein Dependency-Pfad bewusst gewaehlt. Diese Patterns sind im routine-anlegen-Skill hardgecodet — neue Routinen muessen das nicht erneut entdecken.
1. Bedrock Inference-Profile-ARN braucht Account-ID-Slot
Bug: Diese IAM-Policy-Resource matched die echte Inference-Profile-ARN nicht:
resources: ['arn:aws:bedrock:eu-*::inference-profile/eu.anthropic.claude-haiku-4-5-*']Warum: Cross-Region-Inference-Profiles (alles was eu.* oder global.* heisst) sind per-Account materialisiert, nicht service-owned. Die echte ARN hat die Account-ID drin:
arn:aws:bedrock:eu-central-1:425924867359:inference-profile/eu.anthropic.claude-haiku-4-5-20251001-v1:0
Foundation-Model-ARNs sind anders — die sind service-owned und haben keine Account-ID:
arn:aws:bedrock:eu-central-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
Fix: beide Patterns in der Policy, mit Account-Slot fuer Inference-Profile:
resources: [
`arn:aws:bedrock:eu-*::foundation-model/anthropic.claude-haiku-4-5-*`,
`arn:aws:bedrock:eu-*:${this.role.env.account}:inference-profile/eu.anthropic.claude-haiku-4-5-*`,
]2. Secrets-Manager-ARN braucht -* Suffix
Bug: Diese IAM-Policy-Resource matched das echte Secret nicht:
resources: ['arn:aws:secretsmanager:eu-central-1:425924867359:secret:agent-platform/telegram-bot-token']Warum: Secrets Manager haengt pro Secret ein zufaelliges 6-Zeichen-Suffix an die ARN — z.B. agent-platform/telegram-bot-token-JrqAPh. Ohne den Wildcard matched die Policy keinen einzigen Versions-ARN.
Fix:
resources: ['arn:aws:secretsmanager:eu-central-1:425924867359:secret:agent-platform/telegram-bot-token-*']Der Lambda-Code (secretsmanager:GetSecretValue via SecretId mit agent-platform/... als Name) funktioniert intern korrekt — der Bug ist nur im IAM-Resource-Matching.
3. Bedrock Modell-ID braucht Date-Tag
Bug: Diese Model-ID wirft ValidationException: The provided model identifier is invalid:
modelId="eu.anthropic.claude-haiku-4-5-v1:0"Warum: Die echte Inference-Profile-ID hat ein Datums-Tag zwischen Version und Suffix:
eu.anthropic.claude-haiku-4-5-20251001-v1:0
Verifikation via:
aws bedrock list-inference-profiles --region eu-central-1 \
--query 'inferenceProfileSummaries[?contains(inferenceProfileId, `haiku`)].inferenceProfileId'Das Datums-Tag ist der Modell-Release-Timestamp, nicht ein dynamisches Feld. Aktuelle Default-ID (Stand 2026-05-12):
eu.anthropic.claude-haiku-4-5-20251001-v1:0
4. EventBridge ohne Timezone — Berlin-Zeit per Doppel-Trigger
Problem: AWS EventBridge-Cron versteht nur UTC. Beim Sommer-/Winterzeit-Wechsel verschiebt sich die Berliner Zeit relativ zu UTC um eine Stunde.
Naive Loesung (fix UTC): Marvin haendisch im Maerz + Oktober nachpflegen — geht schief.
Saubere Loesung: Cron triggert zwei UTC-Zeiten Mo-Fr, Lambda prueft intern Berlin-Zeit und skippt den falschen Trigger.
schedule: events.Schedule.cron({
minute: '30',
hour: '7,8',
weekDay: 'MON-FRI',
})from datetime import datetime
from zoneinfo import ZoneInfo
BERLIN = ZoneInfo("Europe/Berlin")
now = datetime.now(BERLIN)
if not (now.hour == 9 and 25 <= now.minute <= 34):
return {"status": "skipped", "reason": f"not-930-berlin ({now.strftime('%H:%M')})"}Sommerzeit: 7:30 UTC = 9:30 Berlin (laeuft), 8:30 UTC = 10:30 Berlin (skip). Winterzeit: 8:30 UTC = 9:30 Berlin (laeuft), 7:30 UTC = 8:30 Berlin (skip).
2 Invokes/Tag, einer davon ist no-op (~10 ms billable). Kein manueller Eingriff bei Zeitumstellung.
Python 3.12 hat zoneinfo stdlib. Lambda-Runtime hat die /usr/share/zoneinfo/-Files. tzdata pip-Dependency nicht noetig.
5. Lambda ohne pip-Bundling — stdlib + boto3 + urllib3
Entscheidung: Solange eine Routine nur HTTP-Calls macht (Gmail-API, GitHub-API, Telegram-API) plus AWS-Services (Bedrock, S3, Secrets), reicht urllib3 (kommt mit boto3 preinstalled). Kein httpx, kein google-api-python-client, kein PyGithub.
Konsequenz: kein pip install -r requirements.txt -t /asset-output/python im CDK-Lambda-Bundling. Layer und Code-Folder sind plain Python, CDK packt sie 1:1.
Wann pip-Bundling notwendig wird: PDF-Parsing (pypdfium2), Google-API mit interner Auth (google-auth), Pandas/Numpy, native Crypto. Dann erweitert man lambda.Code.fromAsset mit bundling: { command: ['bash', '-c', 'pip install -r requirements.txt -t /asset-output && cp -r . /asset-output'] }.
Pattern fuer urllib3-OAuth-Flow (Gmail/Calendar):
import urllib.parse, urllib3, json
pool = urllib3.PoolManager(timeout=urllib3.Timeout(connect=5, read=15))
body = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": client_id,
"client_secret": client_secret,
}).encode()
resp = pool.request("POST", "https://oauth2.googleapis.com/token", body=body,
headers={"Content-Type": "application/x-www-form-urlencoded"})
access_token = json.loads(resp.data)["access_token"]6. Gmail-OAuth-Refresh in Secrets Manager — Bundle statt nur Token
Falsch (Briefing-Default): agent-platform/gmail-oauth-refresh = nur refresh_token-String pro Konto.
Richtig: Bundle pro Konto mit allen vier Feldern die Lambda fuer den Refresh-Flow braucht:
{
"hello": {
"refresh_token": "...",
"client_id": "...apps.googleusercontent.com",
"client_secret": "...",
"token_uri": "https://oauth2.googleapis.com/token"
},
"privat": { "..." }
}Warum: Refresh-Flow braucht refresh_token + client_id + client_secret. Ohne client_id/secret kann Lambda nicht refreshen, auch wenn refresh_token gueltig ist. Bundle macht Lambda self-contained — kein Mapping ueber Env-Vars und Code-Updates noetig wenn client_id rotiert.
Bundle-Extraktion aus mcp-gsuite-Files:
import json
from pathlib import Path
cfg = Path.home() / ".config/mcp-gsuite"
hello = json.loads((cfg / ".oauth2.hello@marvinkuehlmann.com.json").read_text())
privat = json.loads((cfg / ".oauth2.marvinkuehlmann@gmail.com.json").read_text())
bundle = {
"hello": {k: hello[k] for k in ("refresh_token", "client_id", "client_secret", "token_uri")},
"privat": {k: privat[k] for k in ("refresh_token", "client_id", "client_secret", "token_uri")},
}Beide Konten haben i.d.R. den gleichen OAuth-Client (single Google-Cloud-Project, multiple User-Tokens) — schadet aber nicht, ist explizit.
7. Lambda als GitHub-Committer (Vault-Archiv-Pattern)
daily-briefing schreibt taeglich ein operations/briefings/<datum>.md ins Vault-Repo via GitHub-API. Pattern dazu in lambdas/daily-briefing/github_client.py:
def upsert_file(owner, repo, path, pat, content, message, branch="main"):
existing = get_file(owner, repo, path, pat, ref=branch)
sha = existing["sha"] if existing else None
body = {
"message": message,
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"branch": branch,
}
if sha:
body["sha"] = sha
# PUT /repos/{owner}/{repo}/contents/{path}Idempotent — gleicher Tag = Update, neuer Tag = Create. GitHub-API erlaubt direkt-commit auf main, kein PR-Detour. Fine-Grained-PAT mit Contents Read+Write reicht.
8. Repo-Divergenz (Vault v1.4) — drei Wege weiter
Problem: wenn lokal und Remote keine gemeinsame Git-History haben (Marvin’s Vault-Fresh-Start mit „Initial commit: Vault v1.4 (intern/extern/assets/_meta-Struktur)”), funktioniert weder git pull noch git push ohne --force.
Drei Optionen:
| Option | Befehl | Wer behaelt was |
|---|---|---|
| A) Force-push lokal nach Remote | git push --force-with-lease origin main | Lokal gewinnt, Remote verliert alle Commits die lokal nicht sind (inkl. heutiges Lambda-Briefing-Archiv + gh-CLI-action-items-Push) |
| B) Lokal aufgeben | git reset --hard origin/main | Remote gewinnt, lokale Commits sind weg |
| C) Lokal in Side-Branch parken | git branch vault-v1.4-migration && git reset --hard origin/main | Beide ueberleben, Vault-v1.4-Migration laeuft als eigenes Projekt |
Empfehlung: Option C — Lambda-API-Commits leben auf Remote, lokale Vault-v1.4-Arbeit auf Side-Branch, Marvin migriert wenn er Lust hat. Lambda-Routinen lesen weiterhin Remote-Stand.
9. Gmail-API: metadataHeaders braucht doseq=True
Bug (2026-05-17): Gmail-Metadaten-Fetch liefert leere headers-Liste — Subject, From, Date kommen nicht an. Output: alle Mails zeigen “(unbekannt)” als Absender und “(kein Betreff)“.
Ursache: urllib.parse.urlencode({"metadataHeaders": "Subject,From,Date"}) erzeugt:
metadataHeaders=Subject%2CFrom%2CDate
Die Gmail-API ignoriert komma-separierte Header-Listen komplett. Sie erwartet wiederholte Query-Parameter:
metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date
Fix: Liste als Wert + doseq=True beim Encoding:
# gmail_client.py — _api_get()
url += "?" + urllib.parse.urlencode(params, doseq=True)
# get_message_metadata() — params
params={
"format": "metadata",
"metadataHeaders": ["Subject", "From", "Date", "List-Unsubscribe", "Auto-Submitted"],
}Mit doseq=True verarbeitet urlencode Listen korrekt zu wiederholten Keys. Ohne den Fix bekam die Gmail-API einen einzigen komma-kodierten Wert, der stillschweigend ignoriert wird — kein Fehler, nur leere Header.
Gilt fuer alle Gmail-API-Calls mit Listen-Parametern (z.B. labelIds, fields mit Arrays).
10. Email-Triage Security-Architektur: readonly + kein Gmail-Write aus Lambda
Prinzip (2026-05-17): Automatisierte Lambdas duerfen niemals in Mailboxen schreiben — auch nicht als Draft. Sicherheitsgrenze: OAuth-Scope.
Zwei unabhaengige Schutzschichten:
Schicht 1 — OAuth-Scope:
Das Gmail-Secret das die Lambda nutzt hat nur gmail.readonly. Mit diesem Scope verweigert die Gmail-API jeden POST-Request mit HTTP 403 — auch wenn der Code versucht zu schreiben. Selbst wenn Bedrock einen Draft generiert und create_draft() aufgerufen wuerde, schlaegt die API-Call fehl.
gmail.readonly: nur Lesen — kein Draft, kein Send, kein Label-Changegmail.compose: Drafts erstellen UND senden — zu weit fuer Lambdagmail.modify: Labels/Archivieren — nicht benoetigt
Schicht 2 — Kein Write-Code im Lambda:
_try_create_gmail_draft() existiert nicht in der automatisierten Pipeline. Draft-Text lebt ausschliesslich als String in email-triage.json auf S3. Marvin erstellt Drafts manuell (Copy-Paste im Cockpit oder gsuite-MCP in Claude).
Konsequenz fuer gmail_client.py:
create_draft() bleibt in der Library — aber nur fuer interaktive Tools (Claude-Sessions, gsuite-MCP). Kommentar im Code verweist explizit darauf. So wird nie versehentlich aus einer Routine aufgerufen.
def create_draft(...):
"""
VERWENDUNG: Nur fuer interaktive/manuelle Tools (gsuite-MCP, Claude-Skill).
Nicht aus automatisierten Lambdas aufrufen — die nutzen nur gmail.readonly.
"""11. Vault-Kontext-Injection fuer Lambdas (generate-context Pattern)
Problem: Lambda laeuft in AWS, hat keinen Zugriff auf lokale Vault-Markdown-Files. Email-Triage braucht aber Projekt-Kontext (aktive Projekte, Kunden) damit Bedrock Mails korrekt zuordnen kann.
Loesung: Build-Time-Script generiert kompaktes JSON aus dem Vault → Upload nach S3-DataBucket → Lambda liest von dort.
# Ablauf:
deploy.sh → generate-context.mjs --upload → S3/api/data/project-context.json
Lambda → s3.get_object() → project_context String → Bedrock-System-Prompt
Script: av-cockpit/scripts/generate-context.mjs (Node.js ESM, gray-matter + glob)
- Liest
intern/projekte/**/_index.md+intern/kunden/*.mdFrontmatter - Filtert cold/done/abgebrochen Projekte raus
- Schreibt kompaktes JSON nach
.context-build/project-context.json(gitignored) --uploadFlag synct direkt nach S3 mitaws s3 cp
Wichtig: Output-Pfad ist nicht public/ — das wuerde Vault-Daten via CloudFront oeffentlich machen. Temporaeres Build-Verzeichnis .context-build/ ist gitignored.
Lambda-Fallback: wenn project-context.json nicht in S3 liegt (erster Deploy), loggt Lambda INFO und faehrt mit leerem Kontext-String weiter. Kein Fehler, nur weniger praezise Klassifizierung.
Verwendung beim Anlegen neuer Routinen
Der routine-anlegen-Skill (SKILL.md) hat diese Patterns als hardcoded Defaults in Phase 2 eingebaut. Wenn du eine neue Routine via Skill anlegst, kriegst du automatisch:
- Date-Tag-Modell-ID in der Stack-Env-Var
- Inference-Profile-ARN mit Account-Slot in der
agent-construct.ts-Policy - Secrets-ARN mit
-*Suffix inapp.ts - urllib3-statt-httpx-Default im Lambda-Code
- EventBridge-Cron mit Berlin-Zeitcheck-Pattern im Handler (wenn Zeitzone relevant)
Du musst nichts davon manuell pruefen oder erinnern.