Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ components:
- scope
- created_at
type: object
AccountDailyLimit:
additionalProperties: true
properties:
clean_active_days:
format: int64
type: integer
limit:
format: int64
type: integer
resets_at:
format: date-time
type: string
shared_limit:
format: int64
type: integer
shared_used:
format: int64
type: integer
used:
format: int64
type: integer
required:
- limit
- used
- shared_limit
- shared_used
- clean_active_days
- resets_at
type: object
AccountMetricsView:
additionalProperties: true
properties:
Expand Down Expand Up @@ -174,6 +203,9 @@ components:
properties:
agent_email:
type: string
daily_limit:
$ref: "#/components/schemas/AccountDailyLimit"
description: External-recipient allowance for this UTC day. Used includes pending or uncertain provider submissions. Shared-identity usage is included in total usage and also bounded by shared_limit. Internal recipients (own live agents and verified owner mailbox) do not count. Omitted when this deployment does not enable the account trust ladder.
deleted_at:
description: When the account was moved to the trash. Absent for a live account.
format: date-time
Expand Down Expand Up @@ -2658,6 +2690,9 @@ components:
description: The account's usage at the time the cap was hit (matches usage.<resource>).
format: int64
type: integer
daily_limit:
$ref: "#/components/schemas/AccountDailyLimit"
description: Current external-recipient allowance, usage and reset time when the account trust ladder refuses an immediate send.
limit:
description: The cap that was hit (matches limits.max_<resource>).
format: int64
Expand All @@ -2666,7 +2701,7 @@ components:
description: The account's plan label.
type: string
resource:
description: "The capped resource stem. For stems with AccountView fields, key it to usage.<resource> and limits.max_<resource>. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: agents, domains, messages_month, storage_bytes, messages_day (per-UTC-day send cap; no AccountView field — resets at midnight UTC)."
description: "The capped resource stem. For stems with AccountView fields, key it to usage.<resource> and limits.max_<resource>. Open set: new values may be added over time, so treat these as strings and tolerate unknown values. Known values: agents, domains, messages_month, storage_bytes, messages_day (daily send cap; daily_limit reports external-recipient usage on deployments with the account trust ladder — resets at midnight UTC)."
type: string
upgrade_url:
description: An upgrade affordance URL, when the operator has configured one.
Expand Down Expand Up @@ -4527,7 +4562,7 @@ components:
format: int64
type: integer
daily_recipient_limit:
description: Current UTC-day recipient allowance. Zero means no ramp cap applies.
description: Current UTC-day recipient allowance. Zero means no per-domain ramp cap applies; account daily_limit can still apply.
format: int64
type: integer
estimated_completion_at:
Expand All @@ -4545,7 +4580,7 @@ components:
format: date-time
type: string
status:
description: "Platform-managed sending-ramp state. Open set; known values: inactive, ramping, complete, exempt."
description: "Platform-managed sending-ramp state. Open set; known values: inactive, ramping, complete, exempt, account_managed (the account daily_limit replaces the per-domain ramp)."
type: string
required:
- status
Expand Down
9 changes: 9 additions & 0 deletions cli/src/__tests__/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ describe("whoami command", () => {
vi.clearAllMocks();
});

it("shows the external daily allowance and shared subset", async () => {
mockAccountGet.mockResolvedValue(makeAccount({dailyLimit:{limit:88,used:17,sharedLimit:50,sharedUsed:6,cleanActiveDays:1,resetsAt:new Date("2026-01-02T00:00:00Z")}}));
const {whoami}=await import("../commands/whoami.js");await whoami({});
const output=mockStdout.mock.calls.map((c:unknown[])=>c[0]).join("");
expect(output).toContain("daily: 17/88 external recipients");
expect(output).toContain("shared identity: 6/50");
expect(output).toContain("2026-01-02T00:00:00.000Z");
});

it("prints identity, scope, plan, and usage for an account key", async () => {
mockAccountGet.mockResolvedValue(makeAccount());
const { whoami } = await import("../commands/whoami.js");
Expand Down
4 changes: 4 additions & 0 deletions cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export async function whoami(opts: WhoamiOptions): Promise<void> {
`usage: ${account.usage.agents}/${account.limits.maxAgents} agents, ` +
`${account.usage.messagesMonth}/${account.limits.maxMessagesMonth} messages this month\n`,
);
if (account.dailyLimit) {
const d=account.dailyLimit;
process.stdout.write(`daily: ${d.used}/${d.limit} external recipients; shared identity: ${d.sharedUsed}/${d.sharedLimit}; resets ${d.resetsAt.toISOString()}\n`);
}
// Additive, optional: only ever present right after a dashboard restore
// from the trash, so most accounts print nothing new here.
if (account.restoredAt) {
Expand Down
1 change: 1 addition & 0 deletions cmd/e2a/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ func main() {
},
time.Duration(cfg.Limits.CacheTTLSeconds)*time.Second,
)
enforcer.SetAccountDailyControl(outboundSending.module.AccountTrustEnabled)
api.SetEnforcer(enforcer)
// Master switch for the outbound footer; the enforcer above carries the
// per-account entitlement + row-less default the decision reads.
Expand Down
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,13 @@ sender_identity:
# established self-host-compatible default of 50, so the hashes intentionally
# differ even though both policies are disabled.
sending_ramp:
# Opt-in replacement: one account allowance (20 to 2,000), with shared
# identity capped at 50 and only external recipients counted. Independent
# of the legacy enabled/schedule fields below. Review existing-account
# seeding before activation; see docs/design/account-trust-ladder.md.
account_trust_enabled: false
# Retire probation/account/shared budget caps; global and notice pools stay.
disable_legacy_daily_budgets: false
enabled: false
start_daily: 50
target_daily: 2000
Expand Down
8 changes: 5 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,13 +708,15 @@ usually a truncated TXT.)

Every domain response also carries **`sending_ramp`** — the platform-managed
recipient-volume ramp state for newly verified custom sender domains:
`status` (open set; known values `inactive | ramping | complete | exempt`),
`daily_recipient_limit` (zero means no cap applies), `recipients_used_today`,
`status` (open set; known values `inactive | ramping | complete | exempt | account_managed`),
`daily_recipient_limit` (zero means no per-domain cap applies), `recipients_used_today`,
`active_days` / `ramp_days`, and `resets_at` / `estimated_completion_at`. You
can read this state but cannot change the schedule, exempt yourself, or reset
progression through the API — see
[`docs/runbooks/sending-ramp.md`](runbooks/sending-ramp.md) for the
operator-side mechanics.
operator-side mechanics. With `account_managed`, the optional `daily_limit`
object on `GET /v1/account` supplies the account-wide external allowance,
reserved usage, shared-identity subset, and UTC reset time.

### Agents (`/v1/agents`)

Expand Down
94 changes: 94 additions & 0 deletions docs/design/account-trust-ladder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Account sending trust ladder

This opt-in control replaces the custom-domain ramp with one account-level
external-recipient allowance. It does not change monthly recipient-delivery
quotas or grant permission to email external recipients. Operator approval,
pauses, suppression, verified sending identity, and provider authorization
continue to apply independently.

## Allowance and accounting

The account starts at 20 external recipients per UTC day. Each completed clean
active day advances a linear schedule: `20 + floor(1980 * min(days, 29) / 29)`.
The thirtieth active day's allowance is 2,000. A day qualifies after at least
one provider-accepted external recipient; idle days and failed attempts earn
nothing. A detector breach disqualifies the day. The detector that records
breaches and reduces trust is a later implementation phase.

Shared-identity recipients count toward the same account allowance and also
have a ceiling of 50 per day. The shared ceiling is a subset, not a separate
allowance. Adding or changing a domain never resets or multiplies trust.
The effective account allowance is the lower of trust and `max_messages_day`:
a null plan cap means unlimited, zero means no external sending, and accounts
without a provisioned limits row conservatively use 20. Thus bare Free remains
at 20; a paid plan or add-on removes only the plan cap, never the trust gate.

External recipients are the admission gate's recipient classes: exclude the
account's live agents and its currently verified owner mailbox. Normalize and
deduplicate the complete To/Cc/Bcc envelope. Internal recipients still count
against monthly quota. This classification is repeated at authorization and
immediately before a provider call, so an old grant cannot acquire new external
recipients after an ownership or verification change. Any count change, including becoming internal, requires a fresh authorization. Legacy/all-recipient attempts never earn trust credit, including across runtime-policy toggles.

Reservations serialize on an account row after the existing account-control,
operation, and budget locks. All identities compete for that same allowance.
Retries reuse the reservation; authoritative rejection/cancellation releases it;
uncertain outcomes keep it. Authoritative late acceptance restores a released
reservation. A refused midnight rollover retains the old reservation so delayed
provider evidence is not lost. Clean-day credit uses the settled attempt’s day and external units, separately from conservative reserved capacity; internal-only acceptance earns no credit. Late activity for a pruned historical bucket is not recreated, preventing a second award for archived days. Final authorization remains the enforcement
boundary; the immediate-send preflight is guidance, not a reservation.

## Configuration and compatibility

Both new settings default to false:

```yaml
sending_ramp:
account_trust_enabled: true
disable_legacy_daily_budgets: true
```

`account_trust_enabled` selects the fixed account schedule instead of the legacy
per-domain ramp, even when the budget mode is disabled. Existing domain history
and legacy self-host behavior remain intact when it is false.
`disable_legacy_daily_budgets` removes the probation, account daily, and account
shared daily caps from budget admission while retaining their counter keys for
safe settlement across policy changes. Platform and operational notice pools
remain independently governed by their existing policy. External-only platform counting starts with `account_trust_enabled`; disabling legacy caps alone retains the existing all-recipient platform accounting. A preceding shadow phase must not interpret those counters as external-only evidence.

The corresponding optional runtime-policy keys are omitted when false, preserving
canonical hashes of policies written before these keys existed. Database-sourced
policy remains authoritative at runtime, including daily quota delegation.

## Visibility

`GET /v1/account` has an optional `daily_limit` object with `limit`, `used`,
`shared_limit`, `shared_used`, `clean_active_days`, and `resets_at`. Usage includes
pending or uncertain reservations. The endpoint returns `limits_unavailable`
when the enabled control cannot be read instead of implying that it is absent.
Internal/system accounts are exempt and omit the object.

An immediate request exceeding the allowance returns 402 `limit_exceeded`,
resource `messages_day`, and the same snapshot in `error.details.daily_limit`.
Scheduled sends are checked when they fire. Queued sends encountering the cap
are held until UTC midnight, subject to the existing finite retry horizon;
queued-message and terminal hold diagnostics include usage, allowance, and reset time.
The generated TypeScript and Python models, CLI `whoami`, MCP `whoami`, and
usage dashboard carry the same contract. No platform-wide capacity is exposed.

## Persistence and rollout

Migrations 130–131 add account trust, daily buckets, message reservations, and immutable per-attempt external-unit provenance without
activating the control or exempting existing accounts. Foreign keys erase these
rows with their owning account. Daily maintenance prunes settled history older
than 90 days, folding clean-day credit into the account row first. It retains
unresolved reservations and their buckets. Each run bounds both accounts and
rows processed and skips active account locks.

This PR supplies implementation, not hosted activation. The activation change
must seed already-approved accounts from reviewed observed external volume before
enabling the ladder; `grandfather_daily` supports a bounded floor (0–2,000) without
bypassing the shared ceiling or plan cap. No send-path code sets that floor.
The hosted policy-source switch, reviewed grandfathering command/payload,
seven-day observation gate, detector automation, and production enablement remain
separate rollout work. User-facing rollout guidance belongs with enablement.
18 changes: 18 additions & 0 deletions internal/agent/external_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/tokencanopy/e2a/internal/outbound"
"github.com/tokencanopy/e2a/internal/sendingpolicy"
"github.com/tokencanopy/e2a/internal/sendramp"
)

// ExternalSendingNotEnabledCode is the stable 403 code for a send the account
Expand Down Expand Up @@ -65,6 +66,23 @@ func (a *API) preflightExternalAccess(ctx context.Context, userID, agentID strin
if verdict.Denied() {
return a.externalSendingNotEnabledError(ctx, userID)
}
if req.ScheduledAt == nil {
if daily, ok := a.externalAccess.(interface {
DailyLimitPreflight(context.Context, string, string, []string) (*sendramp.AccountDailyLimit, error)
}); ok {
d, err := daily.DailyLimitPreflight(ctx, userID, agentID, recipients)
if err != nil {
return &OutboundError{Status: http.StatusServiceUnavailable, Code: "limits_unavailable", Msg: "could not verify daily sending limit; retry shortly"}
}
if d != nil && !d.Allowed {
limit, used := d.Limit, d.Used
if d.SharedBinding {
limit, used = d.SharedLimit, d.SharedUsed
}
return &OutboundError{Status: http.StatusPaymentRequired, Code: "limit_exceeded", Msg: "daily external-recipient allowance reached; wait until the UTC reset", Details: map[string]any{"resource": "messages_day", "limit": limit, "current": used, "daily_limit": d}}
}
}
}
return nil
}

Expand Down
22 changes: 22 additions & 0 deletions internal/agent/external_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,25 @@ func TestDeliverOutboundExternalAccessMessageFollowsUnlocks(t *testing.T) {
})
}
}

func TestDailyLimitSharedRefusalReportsBindingCap(t *testing.T) {
api, store, _, _, pool := setupAsyncAPIWithPool(t)
p := sendingpolicy.DisabledPolicy()
p.AccountTrustEnabled = true
api.SetExternalAccess(sendingpolicy.NewPolicyModule(pool, sendingpolicy.Secrets{}, sendingpolicy.PolicySourceConfig, p))
ctx := context.Background()
user, ag := selfAgent(t, store, "sharedcap")
if _, err := pool.Exec(ctx, `INSERT INTO account_limits(user_id,max_agents,max_domains,max_messages_month,max_storage_bytes,max_messages_day) VALUES($1,100,10,10000,1073741824,NULL) ON CONFLICT(user_id) DO UPDATE SET max_messages_day=NULL`, user.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_sending_trust(user_id,grandfather_daily) VALUES($1,2000)`, user.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_send_days(user_id,day,reserved_count,shared_count) VALUES($1,(clock_timestamp() AT TIME ZONE 'UTC')::date,50,50)`, user.ID); err != nil {
t.Fatal(err)
}
_, e := api.DeliverOutbound(ctx, user, ag, outbound.SendRequest{To: []string{"external@example.test"}, Subject: "synthetic", Body: "test"}, "send", "", nil, nil)
if e == nil || e.Status != 402 || e.Details["limit"] != 50 || e.Details["current"] != 50 {
t.Fatalf("shared refusal: %+v", e)
}
}
9 changes: 9 additions & 0 deletions internal/agent/outbound_async.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ func (a *outboundSendStore) RecordHold(ctx context.Context, messageID string, cl
return a.store.RecordOutboundHold(ctx, messageID, string(class), anchor)
}

// RecordHoldDetail persists only the current claim's safe quota diagnostic.
// The terminal sent/failed transitions clear or replace this field.
func (a *outboundSendStore) RecordHoldDetail(ctx context.Context, messageID string, jobID int64, detail string) error {
return a.store.WithTx(ctx, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE messages SET delivery_detail=$3 WHERE id=$1 AND send_job_id=$2 AND delivery_status IN ('accepted','sending')`, messageID, jobID, messagelifecycle.SafeDiagnostic(detail))
return err
})
}

// SuppressedRecipients backs the SendWorker's pre-provider suppression guard:
// the effective account-wide + exact-agent subset (the store normalizes both
// sides).
Expand Down
15 changes: 14 additions & 1 deletion internal/apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,19 @@ func BuildDeps(p Params) httpapi.Deps {
}
var rampSnapshot func(context.Context, string, string, time.Time) (sendramp.Snapshot, error)
if p.Pool != nil {
rampSnapshot = sendramp.NewStore(p.Pool).Snapshot
legacyRamp := sendramp.NewStore(p.Pool)
rampSnapshot = func(ctx context.Context, user, domain string, now time.Time) (sendramp.Snapshot, error) {
if p.SendingAccess != nil {
enabled, err := p.SendingAccess.AccountTrustEnabled(ctx)
if err != nil {
return sendramp.Snapshot{}, err
}
if enabled {
return sendramp.Snapshot{Status: "account_managed"}, nil
}
}
return legacyRamp.Snapshot(ctx, user, domain, now)
}
}
var listMessageLifecycle httpapi.MessageLifecycleLister
var countAgentMetrics httpapi.AgentMetricsCounter
Expand Down Expand Up @@ -342,6 +354,7 @@ func BuildDeps(p Params) httpapi.Deps {
Metrics: p.Metrics,
}
if p.SendingAccess != nil {
deps.AccountDailyLimit = p.SendingAccess.AccountDailyLimit
deps.SendingAccessStatus = p.SendingAccess.ExternalAccessStatus
deps.SubmitSendingAccessRequest = p.SendingAccess.SubmitAccessRequest
deps.LatestSendingAccessRequest = p.SendingAccess.LatestAccessRequest
Expand Down
10 changes: 6 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,12 @@ type SenderIdentityConfig struct {
// public API. Values are snapshotted when a domain first sends, so later config
// changes do not reshape an in-flight ramp.
type SendingRampConfig struct {
Enabled bool `yaml:"enabled"`
StartDaily int `yaml:"start_daily"`
TargetDaily int `yaml:"target_daily"`
RampDays int `yaml:"ramp_days"`
DisableLegacyDailyBudgets bool `yaml:"disable_legacy_daily_budgets"`
AccountTrustEnabled bool `yaml:"account_trust_enabled"`
Enabled bool `yaml:"enabled"`
StartDaily int `yaml:"start_daily"`
TargetDaily int `yaml:"target_daily"`
RampDays int `yaml:"ramp_days"`
}

// SendingProtectionConfig carries the non-schedule half of the sending
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ func TestSendingRampDefaultsOverridesAndValidation(t *testing.T) {
if err != nil {
t.Fatalf("Load defaults: %v", err)
}
if cfg.SendingRamp.Enabled || cfg.SendingRamp.StartDaily != 50 || cfg.SendingRamp.TargetDaily != 2000 || cfg.SendingRamp.RampDays != 30 {
if cfg.SendingRamp.AccountTrustEnabled || cfg.SendingRamp.DisableLegacyDailyBudgets || cfg.SendingRamp.Enabled || cfg.SendingRamp.StartDaily != 50 || cfg.SendingRamp.TargetDaily != 2000 || cfg.SendingRamp.RampDays != 30 {
t.Fatalf("sending ramp defaults = %+v, want disabled 50/2000/30", cfg.SendingRamp)
}

Expand Down
Loading
Loading