Skip to content

phantom: Phase 10 PR 10-3 email tool upgrade with metadata-gateway key fetch + tags + tenant-salted idempotency + recipient policy#115

Merged
mcheemaa merged 2 commits intomainfrom
feat/2026-05-01-phase10-pr-10-3-email-upgrade
May 1, 2026
Merged

phantom: Phase 10 PR 10-3 email tool upgrade with metadata-gateway key fetch + tags + tenant-salted idempotency + recipient policy#115
mcheemaa merged 2 commits intomainfrom
feat/2026-05-01-phase10-pr-10-3-email-upgrade

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

@mcheemaa mcheemaa commented May 1, 2026

Summary

The in-VM Phantom EmailTool upgrade for Phase 10 (operator-subsidized Resend transactional email). Adds the metadata-gateway key-fetch path, recipient-policy gate, tenant-salted idempotency, three-tag cost-attribution, and a Prometheus counter on top of the existing 109-LOC phantom_send_email tool. Architect doc: Phase 10 architect (§3.4 Option B storage decision, §6 EmailTool surface, §6.8 seven error_kind values, §7 cost attribution, §9.6 tenant-salted idempotency).

What changed

  • src/config/secret-names.ts (new, 67 LOC). Canonical phantom-side mirror of phantomd's AllowedSecretNames; exports the wire-stable constant RESEND_API_KEY_SECRET_NAME = "resend_api_key".
  • src/email/key-fetcher.ts (new, 250 LOC). Gateway-backed ResendKeyFetcher with a 15-minute cache, 401 cache invalidation, and structured error kinds; mirrors src/config/metadata-fetcher.ts. Plus EnvKeyFetcher fallback for local dev / OSS Docker without a gateway.
  • src/email/recipient-policy.ts (new, 156 LOC). owner / unrestricted / list modes; safe parsing; workspace mode reserved for v1.5+ and explicitly rejected today.
  • src/email/metrics.ts (new, 116 LOC). phantom_email_send_total{outcome, purpose} prom-client counter, 8 outcomes (the 7 error_kind values plus ok), private registry per emitter.
  • src/email/tool.ts (extended, +337 LOC). Replaces process.env.RESEND_API_KEY read with the key-fetcher path; adds three-tag set (tenant_id, agent_id, purpose), tenant-salted sha256 idempotency-key derivation, and the seven-kind error taxonomy. Lazy Resend SDK import preserved. Daily cap and from-address invariants preserved.
  • src/index.ts wires the new deps and merges Slack + Email registries via the array form of setMetricsRegistryProvider.
  • src/core/server.ts /metrics route accepts one registry or an array and concatenates the text expositions.
  • src/channels/slack-channel-factory.ts docstring cross-reference to the new shared mirror; the slack-only AllowedSecretNamesMirror stays for backward compatibility with existing tests.
  • CLAUDE.md new Email section documenting the env-var contract (PHANTOM_OWNER_EMAIL, PHANTOM_TENANT_ID, PHANTOM_EMAIL_RECIPIENTS_ALLOWED, PHANTOM_EMAIL_DAILY_CAP, METADATA_BASE_URL), the seven error_kind values, and the cross-repo wire spec.
  • 5 test files (1334 LOC), 90 new test cases.

Total: 14 files changed, 2204 insertions(+), 108 deletions(-).

Cross-repo contract

The wire-stable secret name string "resend_api_key" MUST be byte-equal across:

  • phantomd PR docs: fix MCP setup instructions and add Claude Desktop support #32 (internal/secrets/types.go::AllowedSecretNames).
  • This PR's src/config/secret-names.ts::RESEND_API_KEY_SECRET_NAME and AllowedSecretNamesMirror.
  • This PR's src/email/key-fetcher.ts (consumes the constant).
  • The architect doc §3.5 mirror invariant + §9.1 cross-repo allowlist table + §13.1 PR scope table.

Drift surfaces as HTTP 404 from the metadata gateway (the gateway maps ErrInvalidName to 404 to defeat name enumeration), which the EmailTool surfaces as error_kind = "key_unavailable". The phantom-side mirror tests (src/config/__tests__/secret-names.test.ts) plus phantomd's TestAllowedSecretNames_KnowsResendKey pin the symmetric assertions.

The tenant-salted idempotency input string is ${PHANTOM_TENANT_ID}:${normalizedTo}:${subject}:${utcDate} (architect §9.6). The salt defends Resend's TEAM-scoped idempotency-key namespace from cross-tenant collision when multiple tenants share the same operator-side Resend key.

Failure-mode coverage

The seven error_kind taxonomy maps every tool path to exactly one outcome:

  • recipient_denied: address violated PHANTOM_EMAIL_RECIPIENTS_ALLOWED. No Resend POST is made.
  • rate_limited_local: per-day soft cap hit. No Resend POST is made.
  • key_unavailable: metadata gateway returned 404 / 5xx / network error.
  • rate_limited_resend: Resend returned 429.
  • validation_error: Resend returned 422 (forwards the upstream message so the agent can suggest a fix).
  • service_down: Resend returned 5xx, the SDK threw, or no id in the response.
  • auth_failed: Resend returned 401 (cached key was revoked) OR the gateway rejected the fetch with 401. The fetcher invalidates its cache so the next attempt refetches a rotated key.

The local cap counter increments only on confirmed ok so policy denials and Resend errors do not consume the agent's daily budget. Test coverage exercises every kind end-to-end including a defensive thrown-sender path and a cross-tenant idempotency collision check.

Plaintext-leak guards

Verified across the test suite:

  • The Resend API key is never echoed via console.log, console.warn, or any structured log line. The cache holds the value privately on the fetcher instance; logs only carry the secret NAME plus HTTP status.
  • The error envelope returned to the agent never carries the secret value across key_unavailable, auth_failed, and service_down paths. The defensive thrown-sender catch maps to service_down with a fixed message rather than echoing the exception (which can carry the API key in HTTP-client error formatting).
  • The validation_error path forwards the upstream Resend message verbatim because the agent needs the field detail to suggest a fix; the upstream sender is responsible for not embedding the key in that message (Resend's SDK does not).
  • The cache key is the secret NAME, not the value.

The key-fetcher.test.ts includes an explicit test that captures console.log and console.warn across the cache-hit path and asserts the secret value never appears.

Architect doc link

phantom-cloud-deploy/local/2026-05-01-phase10-resend-architect.md (Phase 10, dated 2026-05-01). Drives the seven-kind taxonomy (§6.8), the storage decision (§3.4 Option B metadata-gateway fetch), the cache TTL (§3.4 + §6.6 fifteen minutes), the cost-attribution channels (§7), and the tenant-salt idempotency derivation (§9.6).

Test plan

  • bun test clean: 2210 pass / 0 fail across 162 files (90 new tests across 5 files).
  • bun run lint clean.
  • bun run typecheck clean.
  • Verified the wire-stable secret name "resend_api_key" matches phantomd PR docs: fix MCP setup instructions and add Claude Desktop support #32's AllowedSecretNames entry.
  • Verified plaintext-leak guards across every error path.
  • Verified the tenant-salt cross-tenant collision defense (two tenants sending the same logical email get DIFFERENT idempotency keys; the same tenant on the same day gets the SAME key).
  • Verified the recipient-policy default is owner-only (no open-relay default).
  • Verified the daily cap counter increments only on confirmed ok.
  • @codex review after push.
  • Live-Resend smoke gated on PHASE10_LIVE_SMOKE=1; not run in CI (architect §11.3); operator runs at release time.

…lted idempotency + recipient policy

Adds the in-VM Phantom email-tool upgrade for operator-subsidized Resend
email per Phase 10 architect (`phantom-cloud-deploy/local/2026-05-01-phase10-resend-architect.md`).

What changed:
- src/config/secret-names.ts (new, 67 LOC). Canonical phantom-side mirror
  of phantomd's AllowedSecretNames; exports the wire-stable constant
  RESEND_API_KEY_SECRET_NAME = "resend_api_key" matching phantomd PR #32.
- src/email/key-fetcher.ts (new, 250 LOC). Gateway-backed ResendKeyFetcher
  (15-min cache, 401 invalidation, structured error kinds; mirrors
  src/config/metadata-fetcher.ts pattern) plus EnvKeyFetcher fallback for
  local dev / OSS Docker.
- src/email/recipient-policy.ts (new, 156 LOC). owner / unrestricted /
  list policy with safe parsing; "workspace" mode reserved for v1.5+ and
  rejected today; default is owner-only when env is unset.
- src/email/metrics.ts (new, 116 LOC). phantom_email_send_total{outcome,
  purpose} prom-client counter with 8 outcomes (the 7 error_kind values
  plus ok). Owns its own private Registry (matches Slack precedent).
- src/email/tool.ts (extended, +337 LOC). Replaces process.env.RESEND_API_KEY
  read with the key-fetcher path; adds 3-tag set (tenant_id, agent_id,
  purpose), tenant-salted sha256 idempotency-key derivation per architect
  §9.6, and the 7-kind error taxonomy per architect §6.8. Lazy SDK import
  preserved. Existing daily cap and from-address invariants preserved.
- src/index.ts (extended). Wires the new deps; merges Slack and Email
  registries via setMetricsRegistryProvider's array form.
- src/core/server.ts (extended). /metrics route accepts a single registry
  or an array; concatenates text expositions by newline.
- src/channels/slack-channel-factory.ts: docstring cross-reference to the
  new shared mirror; the slack-only AllowedSecretNamesMirror stays for
  backward-compat with existing tests.
- 5 test files (1334 LOC across key-fetcher, recipient-policy, metrics,
  tool, and secret-names). 90 new test cases covering the 7 error_kind
  taxonomy, tenant-salt cross-tenant collision defense, plaintext-leak
  guards across every error path, cache TTL eviction, recipient policy
  parsing, and Resend tag sanitization.
- CLAUDE.md: new Email section documenting the env-var contract, the
  7 error_kind values, and the cross-repo wire spec; cross-repo invariant
  section updated for the new mirror home.

Cross-repo invariant: the secret name string `"resend_api_key"` MUST be
byte-equal across phantomd PR #32 (internal/secrets/types.go), this PR's
src/config/secret-names.ts and src/email/key-fetcher.ts. Drift surfaces
as HTTP 404 from the metadata gateway, which the EmailTool surfaces as
error_kind = "key_unavailable".

Tests: bun test 2210 pass / 0 fail across 162 files; bun run lint clean;
bun run typecheck clean.
@mcheemaa
Copy link
Copy Markdown
Member Author

mcheemaa commented May 1, 2026

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector
Copy link
Copy Markdown

Summary

I can’t perform the requested @codex review for this PR because the latest PR comment indicates the Codex code-review quota is currently exhausted for this account.

Given that limitation, I did not make any repository changes, did not create a commit, and did not open a follow-up PR.

What to do next

  • Re-enable code reviews by upgrading / adding credits in the Codex usage settings linked in the PR comment.
  • Then re-trigger with @codex review and I can perform a full review pass on this PR.

View task →

@mcheemaa mcheemaa merged commit 7fd9291 into main May 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant