carlos / platform Public

mail: record the grant, and let an operator cap outlive a tier pass

Source distinguishes a tier default from a deliberate decision, and a tier
recompute never overwrites an operator grant — otherwise a nightly pass
revokes a raised cap silently, days later, with nothing to point at.
Paul Campbell 0d8ebba03feaea2d435c51aea09c57458239f088 parent 74df38e
2 files changed, +84 −0
  • internal/mail/walk.go +29 −0
  • internal/mail/walk_grant_test.go +55 −0
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index 2cedc1de9..60ca11d76 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -441,6 +441,35 @@ func containsString(xs []string, want string) bool {
return false
}
+// ApplyGrant records who authorised this app to send and at what cap.
+//
+// A TIER-sourced grant never overwrites an OPERATOR-sourced one. That is the
+// entire reason Source exists: an operator raising a cap is a decision, and
+// a nightly tier pass that flattened it back would revoke that decision
+// silently, days later, with nothing to point at.
+func ApplyGrant(ctx context.Context, s release.Store, acct, app string,
+ domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) {
+
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Declared == nil {
+ return nil, ErrNotDeclared
+ }
+ if rec.Grant != nil && rec.Grant.Source == "operator" && source != "operator" {
+ return rec, nil
+ }
+ sort.Strings(domains)
+ rec.Grant = &GrantBlock{
+ Domains: domains, DailyCap: dailyCap, Source: source, At: now, By: actor,
+ }
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+
// writeSMTPEnv delivers the five variables. The DERIVED password goes out,
// never the IAM secret — delivering the secret would authenticate against
// nothing and would put a live AWS credential into an app's environment.
diff --git a/internal/mail/walk_grant_test.go b/internal/mail/walk_grant_test.go
new file mode 100644
index 000000000..3661e1133
--- /dev/null
+++ b/internal/mail/walk_grant_test.go
@@ -0,0 +1,55 @@
+package mail
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// An operator's deliberate cap must survive a tier recompute. Source is
+// what distinguishes "this account is on the free tier" from "someone
+// decided this account may send more", and a tier pass that flattened the
+// second into the first would silently revoke a decision (spec §10).
+func TestTierRecomputeDoesNotClobberAnOperatorGrant(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
+ if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP",
+ "n@x.com", "paul@rslw.com", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 5000, "operator", "paul@rslw.com", now); err != nil {
+ t.Fatal(err)
+ }
+ rec, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Grant.DailyCap != 5000 || rec.Grant.Source != "operator" {
+ t.Fatalf("tier recompute clobbered the operator grant: %+v", rec.Grant)
+ }
+}
+
+// A tier change DOES move a tier-sourced cap — that is the whole point of
+// recording the source.
+func TestTierRecomputeMovesATierGrant(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
+ Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "n@x.com", "paul@rslw.com", now)
+ ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", now)
+ rec, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 1000, "tier:starter", "tier", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Grant.DailyCap != 1000 {
+ t.Fatalf("DailyCap = %d, want 1000", rec.Grant.DailyCap)
+ }
+}
+
+func TestGrantRefusesAnUndeclaredApp(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ if _, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", time.Now().UTC()); err != ErrNotDeclared {
+ t.Fatalf("= %v, want ErrNotDeclared", err)
+ }
+}