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:

OptionBefehlWer behaelt was
A) Force-push lokal nach Remotegit push --force-with-lease origin mainLokal gewinnt, Remote verliert alle Commits die lokal nicht sind (inkl. heutiges Lambda-Briefing-Archiv + gh-CLI-action-items-Push)
B) Lokal aufgebengit reset --hard origin/mainRemote gewinnt, lokale Commits sind weg
C) Lokal in Side-Branch parkengit branch vault-v1.4-migration && git reset --hard origin/mainBeide 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-Change
  • gmail.compose: Drafts erstellen UND senden — zu weit fuer Lambda
  • gmail.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/*.md Frontmatter
  • Filtert cold/done/abgebrochen Projekte raus
  • Schreibt kompaktes JSON nach .context-build/project-context.json (gitignored)
  • --upload Flag synct direkt nach S3 mit aws 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 in app.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.