···22# Copy this file to quotas.yaml to enable quota enforcement.
33# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
4455-# Berths define quota tiers using nautical crew ranks.
66-# Each berth has a quota limit specified in human-readable format.
55+# Tiers define quota levels using nautical crew ranks.
66+# Each tier has a quota limit specified in human-readable format.
77# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
88-berths:
88+tiers:
99 # Entry-level crew - suitable for new or casual users
1010 deckhand:
1111 quota: 5GB
···1818 quartermaster:
1919 quota: 100GB
20202121- # You can add custom berths with any name:
2121+ # You can add custom tiers with any name:
2222 # unlimited_crew:
2323 # quota: 1TB
24242525defaults:
2626- # Default berth assigned to new crew members who don't have an explicit berth.
2727- # This berth must exist in the berths section above.
2828- new_crew_berth: deckhand
2626+ # Default tier assigned to new crew members who don't have an explicit tier.
2727+ # This tier must exist in the tiers section above.
2828+ new_crew_tier: deckhand
29293030# Notes:
3131-# - The hold captain (owner) always has unlimited quota regardless of berths.
3232-# - Crew members can be assigned a specific berth in their crew record.
3333-# - If a crew member's berth doesn't exist in config, they fall back to the default.
3131+# - The hold captain (owner) always has unlimited quota regardless of tiers.
3232+# - Crew members can be assigned a specific tier in their crew record.
3333+# - If a crew member's tier doesn't exist in config, they fall back to the default.
3434# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
3535# - Quota is checked when pushing manifests (after blobs are already uploaded).
+11-11
docs/QUOTAS.md
···507507- Email/webhook notifications
508508- Grace period before hard enforcement
509509510510-### 3. Berth-Based Quotas (Implemented)
510510+### 3. Tier-Based Quotas (Implemented)
511511512512-ATCR uses nautical-themed "berths" for quota tiers, configured via `quotas.yaml`:
512512+ATCR uses quota tiers to limit storage per crew member, configured via `quotas.yaml`:
513513514514```yaml
515515# quotas.yaml
516516-berths:
516516+tiers:
517517 deckhand: # Entry-level crew
518518 quota: 5GB
519519 bosun: # Mid-level crew
···522522 quota: 100GB
523523524524defaults:
525525- new_crew_berth: deckhand # Default berth for new crew members
525525+ new_crew_tier: deckhand # Default tier for new crew members
526526```
527527528528-| Berth | Limit | Description |
529529-|-------|-------|-------------|
528528+| Tier | Limit | Description |
529529+|------|-------|-------------|
530530| deckhand | 5 GB | Entry-level crew member |
531531| bosun | 50 GB | Mid-level crew member |
532532| quartermaster | 100 GB | Senior crew member |
533533| owner (captain) | Unlimited | Hold owner always has unlimited |
534534535535-**Berth Resolution:**
535535+**Tier Resolution:**
5365361. If user is captain (owner) → unlimited
537537-2. If crew member has explicit berth → use that berth's limit
538538-3. If crew member has no berth → use `defaults.new_crew_berth`
539539-4. If default berth not found → unlimited
537537+2. If crew member has explicit tier → use that tier's limit
538538+3. If crew member has no tier → use `defaults.new_crew_tier`
539539+4. If default tier not found → unlimited
540540541541**Crew Record Example:**
542542```json
···545545 "member": "did:plc:alice123",
546546 "role": "writer",
547547 "permissions": ["blob:write"],
548548- "berth": "bosun",
548548+ "tier": "bosun",
549549 "addedAt": "2026-01-04T12:00:00Z"
550550}
551551```
+2-2
lexicons/io/atcr/hold/crew.json
···2929 "maxLength": 64
3030 }
3131 },
3232- "berth": {
3232+ "tier": {
3333 "type": "string",
3434- "description": "Optional berth (nautical rank) for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_berth from quotas.yaml.",
3434+ "description": "Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_tier from quotas.yaml.",
3535 "maxLength": 32
3636 },
3737 "addedAt": {
···2727 cw := cbg.NewCborWriter(w)
2828 fieldCount := 6
29293030- if t.Berth == "" {
3030+ if t.Tier == "" {
3131 fieldCount--
3232 }
3333···5858 return err
5959 }
60606161+ // t.Tier (string) (string)
6262+ if t.Tier != "" {
6363+6464+ if len("tier") > 8192 {
6565+ return xerrors.Errorf("Value in field \"tier\" was too long")
6666+ }
6767+6868+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tier"))); err != nil {
6969+ return err
7070+ }
7171+ if _, err := cw.WriteString(string("tier")); err != nil {
7272+ return err
7373+ }
7474+7575+ if len(t.Tier) > 8192 {
7676+ return xerrors.Errorf("Value in field t.Tier was too long")
7777+ }
7878+7979+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tier))); err != nil {
8080+ return err
8181+ }
8282+ if _, err := cw.WriteString(string(t.Tier)); err != nil {
8383+ return err
8484+ }
8585+ }
8686+6187 // t.Type (string) (string)
6288 if len("$type") > 8192 {
6389 return xerrors.Errorf("Value in field \"$type\" was too long")
···79105 }
80106 if _, err := cw.WriteString(string(t.Type)); err != nil {
81107 return err
8282- }
8383-8484- // t.Berth (string) (string)
8585- if t.Berth != "" {
8686-8787- if len("berth") > 8192 {
8888- return xerrors.Errorf("Value in field \"berth\" was too long")
8989- }
9090-9191- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("berth"))); err != nil {
9292- return err
9393- }
9494- if _, err := cw.WriteString(string("berth")); err != nil {
9595- return err
9696- }
9797-9898- if len(t.Berth) > 8192 {
9999- return xerrors.Errorf("Value in field t.Berth was too long")
100100- }
101101-102102- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Berth))); err != nil {
103103- return err
104104- }
105105- if _, err := cw.WriteString(string(t.Berth)); err != nil {
106106- return err
107107- }
108108 }
109109110110 // t.Member (string) (string)
···240240241241 t.Role = string(sval)
242242 }
243243- // t.Type (string) (string)
244244- case "$type":
243243+ // t.Tier (string) (string)
244244+ case "tier":
245245246246 {
247247 sval, err := cbg.ReadStringWithMax(cr, 8192)
···249249 return err
250250 }
251251252252- t.Type = string(sval)
252252+ t.Tier = string(sval)
253253 }
254254- // t.Berth (string) (string)
255255- case "berth":
254254+ // t.Type (string) (string)
255255+ case "$type":
256256257257 {
258258 sval, err := cbg.ReadStringWithMax(cr, 8192)
···260260 return err
261261 }
262262263263- t.Berth = string(sval)
263263+ t.Type = string(sval)
264264 }
265265 // t.Member (string) (string)
266266 case "member":
+2-2
pkg/atproto/lexicon.go
···594594 Member string `json:"member" cborgen:"member"`
595595 Role string `json:"role" cborgen:"role"`
596596 Permissions []string `json:"permissions" cborgen:"permissions"`
597597- Berth string `json:"berth,omitempty" cborgen:"berth,omitempty"` // Optional berth for quota limits (nautical rank)
598598- AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
597597+ Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
598598+ AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
599599}
600600601601// LayerRecord represents metadata about a container layer stored in the hold
+2-2
pkg/hold/oci/xrpc.go
···2424 pds *pds.HoldPDS
2525 httpClient pds.HTTPClient
2626 enableBlueskyPosts bool
2727- quotaMgr *quota.Manager // Quota manager for berth-based limits
2727+ quotaMgr *quota.Manager // Quota manager for tier-based limits
2828}
29293030// NewXRPCHandler creates a new OCI XRPC handler
···281281 if operation == "push" {
282282 // Soft limit check: block if ALREADY over quota
283283 // (blobs already uploaded to S3 by this point, no sense rejecting)
284284- stats, err := h.pds.GetQuotaForUserWithBerth(ctx, req.UserDID, h.quotaMgr)
284284+ stats, err := h.pds.GetQuotaForUserWithTier(ctx, req.UserDID, h.quotaMgr)
285285 if err == nil && stats.Limit != nil && stats.TotalSize > *stats.Limit {
286286 slog.Warn("Quota exceeded for push",
287287 "userDid", req.UserDID,
+12-12
pkg/hold/pds/layer.go
···6767 UniqueBlobs int `json:"uniqueBlobs"`
6868 TotalSize int64 `json:"totalSize"`
6969 Limit *int64 `json:"limit,omitempty"` // nil = unlimited
7070- Berth string `json:"berth,omitempty"` // nautical rank for quota tier
7070+ Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster')
7171}
72727373// GetQuotaForUser calculates storage quota for a specific user
···164164 }, nil
165165}
166166167167-// GetQuotaForUserWithBerth calculates quota with berth-aware limits
168168-// It returns the base quota stats plus the berth limit and berth name.
167167+// GetQuotaForUserWithTier calculates quota with tier-aware limits
168168+// It returns the base quota stats plus the tier limit and tier name.
169169// Captain (owner) always has unlimited quota.
170170-func (p *HoldPDS) GetQuotaForUserWithBerth(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
170170+func (p *HoldPDS) GetQuotaForUserWithTier(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
171171 // Get base stats
172172 stats, err := p.GetQuotaForUser(ctx, userDID)
173173 if err != nil {
···182182 // Check if user is captain (owner) - always unlimited
183183 _, captain, err := p.GetCaptainRecord(ctx)
184184 if err == nil && captain.Owner == userDID {
185185- stats.Berth = "owner"
185185+ stats.Tier = "owner"
186186 // Limit remains nil (unlimited)
187187 return stats, nil
188188 }
189189190190- // Get crew record to find berth
191191- crewBerth := p.getCrewBerth(ctx, userDID)
190190+ // Get crew record to find tier
191191+ crewTier := p.getCrewTier(ctx, userDID)
192192193193 // Resolve limit from quota manager
194194- stats.Limit = quotaMgr.GetBerthLimit(crewBerth)
195195- stats.Berth = quotaMgr.GetBerthName(crewBerth)
194194+ stats.Limit = quotaMgr.GetTierLimit(crewTier)
195195+ stats.Tier = quotaMgr.GetTierName(crewTier)
196196197197 return stats, nil
198198}
199199200200-// getCrewBerth returns the berth for a crew member, or empty string if not found
201201-func (p *HoldPDS) getCrewBerth(ctx context.Context, userDID string) string {
200200+// getCrewTier returns the tier for a crew member, or empty string if not found
201201+func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
202202 crewMembers, err := p.ListCrewMembers(ctx)
203203 if err != nil {
204204 return ""
···206206207207 for _, member := range crewMembers {
208208 if member.Record.Member == userDID {
209209- return member.Record.Berth
209209+ return member.Record.Tier
210210 }
211211 }
212212
+53-53
pkg/hold/pds/layer_test.go
···328328 return pds, cleanup
329329}
330330331331-// addCrewMemberWithBerth adds a crew member with a specific berth (nautical rank)
332332-func addCrewMemberWithBerth(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, berth string) {
331331+// addCrewMemberWithTier adds a crew member with a specific tier
332332+func addCrewMemberWithTier(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, tier string) {
333333 t.Helper()
334334335335 crewRecord := &atproto.CrewRecord{
···337337 Member: memberDID,
338338 Role: role,
339339 Permissions: permissions,
340340- Berth: berth,
340340+ Tier: tier,
341341 AddedAt: "2026-01-04T12:00:00Z",
342342 }
343343344344 _, _, err := pds.repomgr.CreateRecord(sharedCtx, pds.uid, atproto.CrewCollection, crewRecord)
345345 if err != nil {
346346- t.Fatalf("Failed to add crew member with berth: %v", err)
346346+ t.Fatalf("Failed to add crew member with tier: %v", err)
347347 }
348348}
349349350350-func TestGetQuotaForUserWithBerth_OwnerUnlimited(t *testing.T) {
350350+func TestGetQuotaForUserWithTier_OwnerUnlimited(t *testing.T) {
351351 ownerDID := "did:plc:owner123"
352352 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
353353 defer cleanup()
···358358 tmpDir := t.TempDir()
359359 configPath := filepath.Join(tmpDir, "quotas.yaml")
360360 configContent := `
361361-berths:
361361+tiers:
362362 deckhand:
363363 quota: 5GB
364364 bosun:
365365 quota: 50GB
366366367367defaults:
368368- new_crew_berth: deckhand
368368+ new_crew_tier: deckhand
369369`
370370 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
371371 t.Fatalf("Failed to write quota config: %v", err)
···391391 }
392392393393 // Get quota for owner
394394- stats, err := pds.GetQuotaForUserWithBerth(ctx, ownerDID, quotaMgr)
394394+ stats, err := pds.GetQuotaForUserWithTier(ctx, ownerDID, quotaMgr)
395395 if err != nil {
396396- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
396396+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
397397 }
398398399399 // Owner should have unlimited quota (nil limit)
···401401 t.Errorf("Expected nil limit for owner, got %d", *stats.Limit)
402402 }
403403404404- // Berth should be "owner"
405405- if stats.Berth != "owner" {
406406- t.Errorf("Expected berth 'owner', got %q", stats.Berth)
404404+ // Tier should be "owner"
405405+ if stats.Tier != "owner" {
406406+ t.Errorf("Expected tier 'owner', got %q", stats.Tier)
407407 }
408408409409 // Should have 3 unique blobs
···420420 t.Logf("Owner quota stats: %+v", stats)
421421}
422422423423-func TestGetQuotaForUserWithBerth_CrewWithDefaultBerth(t *testing.T) {
423423+func TestGetQuotaForUserWithTier_CrewWithDefaultTier(t *testing.T) {
424424 ownerDID := "did:plc:owner456"
425425 crewDID := "did:plc:crew123"
426426 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···432432 tmpDir := t.TempDir()
433433 configPath := filepath.Join(tmpDir, "quotas.yaml")
434434 configContent := `
435435-berths:
435435+tiers:
436436 deckhand:
437437 quota: 5GB
438438 bosun:
439439 quota: 50GB
440440441441defaults:
442442- new_crew_berth: deckhand
442442+ new_crew_tier: deckhand
443443`
444444 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
445445 t.Fatalf("Failed to write quota config: %v", err)
···450450 t.Fatalf("Failed to create quota manager: %v", err)
451451 }
452452453453- // Add crew member with no berth (should use default)
454454- addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
453453+ // Add crew member with no tier (should use default)
454454+ addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
455455456456 // Create layer records for crew member
457457 for i := range 2 {
···468468 }
469469470470 // Get quota for crew member
471471- stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
471471+ stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
472472 if err != nil {
473473- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
473473+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
474474 }
475475476476- // Should have 5GB limit (deckhand berth)
476476+ // Should have 5GB limit (deckhand tier)
477477 expectedLimit := int64(5 * 1024 * 1024 * 1024)
478478 if stats.Limit == nil {
479479 t.Fatal("Expected non-nil limit for crew member")
···482482 t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
483483 }
484484485485- // Berth should be "deckhand"
486486- if stats.Berth != "deckhand" {
487487- t.Errorf("Expected berth 'deckhand', got %q", stats.Berth)
485485+ // Tier should be "deckhand"
486486+ if stats.Tier != "deckhand" {
487487+ t.Errorf("Expected tier 'deckhand', got %q", stats.Tier)
488488 }
489489490490 // Should have 2 unique blobs
···492492 t.Errorf("Expected 2 unique blobs, got %d", stats.UniqueBlobs)
493493 }
494494495495- t.Logf("Crew (deckhand berth) quota stats: %+v", stats)
495495+ t.Logf("Crew (deckhand tier) quota stats: %+v", stats)
496496}
497497498498-func TestGetQuotaForUserWithBerth_CrewWithExplicitBerth(t *testing.T) {
498498+func TestGetQuotaForUserWithTier_CrewWithExplicitTier(t *testing.T) {
499499 ownerDID := "did:plc:owner789"
500500 crewDID := "did:plc:bosuncrew456"
501501 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···507507 tmpDir := t.TempDir()
508508 configPath := filepath.Join(tmpDir, "quotas.yaml")
509509 configContent := `
510510-berths:
510510+tiers:
511511 deckhand:
512512 quota: 5GB
513513 bosun:
514514 quota: 50GB
515515516516defaults:
517517- new_crew_berth: deckhand
517517+ new_crew_tier: deckhand
518518`
519519 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
520520 t.Fatalf("Failed to write quota config: %v", err)
···525525 t.Fatalf("Failed to create quota manager: %v", err)
526526 }
527527528528- // Add crew member with explicit "bosun" berth
529529- addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
528528+ // Add crew member with explicit "bosun" tier
529529+ addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
530530531531 // Create layer records for crew member
532532 record := atproto.NewLayerRecord(
···541541 }
542542543543 // Get quota for crew member
544544- stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
544544+ stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
545545 if err != nil {
546546- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
546546+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
547547 }
548548549549- // Should have 50GB limit (bosun berth)
549549+ // Should have 50GB limit (bosun tier)
550550 expectedLimit := int64(50 * 1024 * 1024 * 1024)
551551 if stats.Limit == nil {
552552 t.Fatal("Expected non-nil limit for crew member")
···555555 t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
556556 }
557557558558- // Berth should be "bosun"
559559- if stats.Berth != "bosun" {
560560- t.Errorf("Expected berth 'bosun', got %q", stats.Berth)
558558+ // Tier should be "bosun"
559559+ if stats.Tier != "bosun" {
560560+ t.Errorf("Expected tier 'bosun', got %q", stats.Tier)
561561 }
562562563563- t.Logf("Crew (bosun berth) quota stats: %+v", stats)
563563+ t.Logf("Crew (bosun tier) quota stats: %+v", stats)
564564}
565565566566-func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
566566+func TestGetQuotaForUserWithTier_NoQuotaManager(t *testing.T) {
567567 ownerDID := "did:plc:ownerabc"
568568 crewDID := "did:plc:crewabc"
569569 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···572572 ctx := sharedCtx
573573574574 // Add crew member
575575- addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
575575+ addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
576576577577 // Create layer record
578578 record := atproto.NewLayerRecord(
···587587 }
588588589589 // Get quota with nil quota manager (no enforcement)
590590- stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, nil)
590590+ stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, nil)
591591 if err != nil {
592592- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
592592+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
593593 }
594594595595 // Should have nil limit (unlimited)
···597597 t.Errorf("Expected nil limit when quota manager is nil, got %d", *stats.Limit)
598598 }
599599600600- // Berth should be empty
601601- if stats.Berth != "" {
602602- t.Errorf("Expected empty berth, got %q", stats.Berth)
600600+ // Tier should be empty
601601+ if stats.Tier != "" {
602602+ t.Errorf("Expected empty tier, got %q", stats.Tier)
603603 }
604604605605 t.Logf("No quota manager stats: %+v", stats)
606606}
607607608608-func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
608608+func TestGetQuotaForUserWithTier_DisabledQuotas(t *testing.T) {
609609 ownerDID := "did:plc:ownerdef"
610610 crewDID := "did:plc:crewdef"
611611 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···624624 }
625625626626 // Add crew member
627627- addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
627627+ addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
628628629629 // Create layer record
630630 record := atproto.NewLayerRecord(
···639639 }
640640641641 // Get quota with disabled quota manager
642642- stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
642642+ stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
643643 if err != nil {
644644- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
644644+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
645645 }
646646647647 // Should have nil limit (unlimited when quotas disabled)
···652652 t.Logf("Disabled quotas stats: %+v", stats)
653653}
654654655655-func TestGetQuotaForUserWithBerth_DeduplicatesBlobs(t *testing.T) {
655655+func TestGetQuotaForUserWithTier_DeduplicatesBlobs(t *testing.T) {
656656 ownerDID := "did:plc:ownerghi"
657657 crewDID := "did:plc:crewghi"
658658 pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
···664664 tmpDir := t.TempDir()
665665 configPath := filepath.Join(tmpDir, "quotas.yaml")
666666 configContent := `
667667-berths:
667667+tiers:
668668 deckhand:
669669 quota: 5GB
670670671671defaults:
672672- new_crew_berth: deckhand
672672+ new_crew_tier: deckhand
673673`
674674 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
675675 t.Fatalf("Failed to write quota config: %v", err)
···681681 }
682682683683 // Add crew member
684684- addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
684684+ addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
685685686686 // Create multiple layer records with same digest (should be deduplicated)
687687 digest := "sha256:duplicatelayer"
···699699 }
700700701701 // Get quota
702702- stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
702702+ stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
703703 if err != nil {
704704- t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
704704+ t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
705705 }
706706707707 // Should have 1 unique blob (deduplicated)
+2-2
pkg/hold/pds/xrpc.go
···15371537 return
15381538 }
1539153915401540- // Get quota stats with berth-aware limits
15411541- stats, err := h.pds.GetQuotaForUserWithBerth(r.Context(), userDID, h.quotaMgr)
15401540+ // Get quota stats with tier-aware limits
15411541+ stats, err := h.pds.GetQuotaForUserWithTier(r.Context(), userDID, h.quotaMgr)
15421542 if err != nil {
15431543 slog.Error("Failed to get quota", "userDid", userDID, "error", err)
15441544 http.Error(w, fmt.Sprintf("failed to get quota: %v", err), http.StatusInternalServerError)
+43-43
pkg/hold/quota/config.go
···13131414// Config represents the quotas.yaml configuration
1515type Config struct {
1616- Berths map[string]BerthConfig `yaml:"berths"`
1717- Defaults DefaultsConfig `yaml:"defaults"`
1616+ Tiers map[string]TierConfig `yaml:"tiers"`
1717+ Defaults DefaultsConfig `yaml:"defaults"`
1818}
19192020-// BerthConfig represents a single berth's configuration
2121-type BerthConfig struct {
2020+// TierConfig represents a single tier's configuration
2121+type TierConfig struct {
2222 Quota string `yaml:"quota"` // Human-readable size: "5GB", "50GB", etc.
2323}
24242525// DefaultsConfig represents default settings
2626type DefaultsConfig struct {
2727- NewCrewBerth string `yaml:"new_crew_berth"`
2727+ NewCrewTier string `yaml:"new_crew_tier"`
2828}
29293030-// Manager manages quota configuration and berth resolution
3030+// Manager manages quota configuration and tier resolution
3131type Manager struct {
3232 config *Config
3333- berths map[string]int64 // resolved berth name -> bytes
3333+ tiers map[string]int64 // resolved tier name -> bytes
3434}
35353636// NewManager creates a quota manager, loading config from file if present
3737func NewManager(configPath string) (*Manager, error) {
3838 m := &Manager{
3939- berths: make(map[string]int64),
3939+ tiers: make(map[string]int64),
4040 }
41414242 // Try to load config file
···56565757 m.config = &cfg
58585959- // Parse and resolve all berths
6060- for name, berth := range cfg.Berths {
6161- bytes, err := ParseHumanBytes(berth.Quota)
5959+ // Parse and resolve all tiers
6060+ for name, tier := range cfg.Tiers {
6161+ bytes, err := ParseHumanBytes(tier.Quota)
6262 if err != nil {
6363- return nil, fmt.Errorf("invalid quota for berth %q: %w", name, err)
6363+ return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err)
6464 }
6565- m.berths[name] = bytes
6565+ m.tiers[name] = bytes
6666 }
67676868 return m, nil
···7373 return m.config != nil
7474}
75757676-// GetBerthLimit resolves the quota limit for a berth key
7777-// Returns nil for unlimited (captain, no config, or berth not found with no default)
7676+// GetTierLimit resolves the quota limit for a tier key
7777+// Returns nil for unlimited (captain, no config, or tier not found with no default)
7878//
7979// Resolution order:
8080// 1. If quotas disabled → nil (unlimited)
8181-// 2. If berthKey provided and found → return that berth's limit
8282-// 3. If berthKey not found or empty → use defaults.new_crew_berth
8383-// 4. If default berth not found → nil (unlimited)
8484-func (m *Manager) GetBerthLimit(berthKey string) *int64 {
8181+// 2. If tierKey provided and found → return that tier's limit
8282+// 3. If tierKey not found or empty → use defaults.new_crew_tier
8383+// 4. If default tier not found → nil (unlimited)
8484+func (m *Manager) GetTierLimit(tierKey string) *int64 {
8585 if !m.IsEnabled() {
8686 return nil
8787 }
88888989- // Try the provided berth key first
9090- if berthKey != "" {
9191- if limit, ok := m.berths[berthKey]; ok {
8989+ // Try the provided tier key first
9090+ if tierKey != "" {
9191+ if limit, ok := m.tiers[tierKey]; ok {
9292 return &limit
9393 }
9494 }
95959696- // Fall back to default berth
9797- if m.config.Defaults.NewCrewBerth != "" {
9898- if limit, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
9696+ // Fall back to default tier
9797+ if m.config.Defaults.NewCrewTier != "" {
9898+ if limit, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
9999 return &limit
100100 }
101101 }
102102103103- // No valid berth found - unlimited
103103+ // No valid tier found - unlimited
104104 return nil
105105}
106106107107-// GetBerthName resolves the berth name for a berth key
108108-// Returns the actual berth name being used (after fallback resolution)
109109-func (m *Manager) GetBerthName(berthKey string) string {
107107+// GetTierName resolves the tier name for a tier key
108108+// Returns the actual tier name being used (after fallback resolution)
109109+func (m *Manager) GetTierName(tierKey string) string {
110110 if !m.IsEnabled() {
111111 return ""
112112 }
113113114114- // Try the provided berth key first
115115- if berthKey != "" {
116116- if _, ok := m.berths[berthKey]; ok {
117117- return berthKey
114114+ // Try the provided tier key first
115115+ if tierKey != "" {
116116+ if _, ok := m.tiers[tierKey]; ok {
117117+ return tierKey
118118 }
119119 }
120120121121- // Fall back to default berth
122122- if m.config.Defaults.NewCrewBerth != "" {
123123- if _, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
124124- return m.config.Defaults.NewCrewBerth
121121+ // Fall back to default tier
122122+ if m.config.Defaults.NewCrewTier != "" {
123123+ if _, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
124124+ return m.config.Defaults.NewCrewTier
125125 }
126126 }
127127128128 return ""
129129}
130130131131-// GetDefaultBerth returns the default berth name for new crew members
132132-func (m *Manager) GetDefaultBerth() string {
131131+// GetDefaultTier returns the default tier name for new crew members
132132+func (m *Manager) GetDefaultTier() string {
133133 if m.config == nil {
134134 return ""
135135 }
136136- return m.config.Defaults.NewCrewBerth
136136+ return m.config.Defaults.NewCrewTier
137137}
138138139139-// BerthCount returns the number of configured berths
140140-func (m *Manager) BerthCount() int {
141141- return len(m.berths)
139139+// TierCount returns the number of configured tiers
140140+func (m *Manager) TierCount() int {
141141+ return len(m.tiers)
142142}
143143144144// ParseHumanBytes parses human-readable byte sizes like "5GB", "100MB", "1.5TB"
+34-34
pkg/hold/quota/config_test.go
···9797 if m.IsEnabled() {
9898 t.Error("expected quotas to be disabled when file missing")
9999 }
100100- if m.GetBerthLimit("anything") != nil {
100100+ if m.GetTierLimit("anything") != nil {
101101 t.Error("expected nil limit when quotas disabled")
102102 }
103103}
···107107 configPath := filepath.Join(tmpDir, "quotas.yaml")
108108109109 configContent := `
110110-berths:
110110+tiers:
111111 deckhand:
112112 quota: 5GB
113113 bosun:
···116116 quota: 100GB
117117118118defaults:
119119- new_crew_berth: deckhand
119119+ new_crew_tier: deckhand
120120`
121121 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
122122 t.Fatalf("failed to write config: %v", err)
···131131 t.Error("expected quotas to be enabled")
132132 }
133133134134- if m.BerthCount() != 3 {
135135- t.Errorf("expected 3 berths, got %d", m.BerthCount())
134134+ if m.TierCount() != 3 {
135135+ t.Errorf("expected 3 tiers, got %d", m.TierCount())
136136 }
137137138138- // Test default berth (empty string)
139139- limit := m.GetBerthLimit("")
138138+ // Test default tier (empty string)
139139+ limit := m.GetTierLimit("")
140140 if limit == nil {
141141- t.Fatal("expected non-nil limit for default berth")
141141+ t.Fatal("expected non-nil limit for default tier")
142142 }
143143 if *limit != 5*1024*1024*1024 {
144144 t.Errorf("expected 5GB limit for default, got %d", *limit)
145145 }
146146147147- // Test explicit berth
148148- limit = m.GetBerthLimit("bosun")
147147+ // Test explicit tier
148148+ limit = m.GetTierLimit("bosun")
149149 if limit == nil {
150150 t.Fatal("expected non-nil limit for bosun")
151151 }
···153153 t.Errorf("expected 50GB limit for bosun, got %d", *limit)
154154 }
155155156156- // Test berth name resolution
157157- if m.GetBerthName("") != "deckhand" {
158158- t.Errorf("expected berth name 'deckhand' for empty key, got %q", m.GetBerthName(""))
156156+ // Test tier name resolution
157157+ if m.GetTierName("") != "deckhand" {
158158+ t.Errorf("expected tier name 'deckhand' for empty key, got %q", m.GetTierName(""))
159159 }
160160- if m.GetBerthName("bosun") != "bosun" {
161161- t.Errorf("expected berth name 'bosun', got %q", m.GetBerthName("bosun"))
160160+ if m.GetTierName("bosun") != "bosun" {
161161+ t.Errorf("expected tier name 'bosun', got %q", m.GetTierName("bosun"))
162162 }
163163}
164164···167167 configPath := filepath.Join(tmpDir, "quotas.yaml")
168168169169 configContent := `
170170-berths:
170170+tiers:
171171 deckhand:
172172 quota: 5GB
173173 quartermaster:
174174 quota: 50GB
175175176176defaults:
177177- new_crew_berth: deckhand
177177+ new_crew_tier: deckhand
178178`
179179 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
180180 t.Fatalf("failed to write config: %v", err)
···185185 t.Fatalf("failed to load config: %v", err)
186186 }
187187188188- // Unknown berth should fall back to default
189189- limit := m.GetBerthLimit("unknown_berth")
188188+ // Unknown tier should fall back to default
189189+ limit := m.GetTierLimit("unknown_tier")
190190 if limit == nil {
191191- t.Fatal("expected fallback to default berth")
191191+ t.Fatal("expected fallback to default tier")
192192 }
193193 if *limit != 5*1024*1024*1024 {
194194 t.Errorf("expected 5GB limit from default fallback, got %d", *limit)
195195 }
196196197197- // Berth name should also fall back
198198- if m.GetBerthName("unknown_berth") != "deckhand" {
199199- t.Errorf("expected berth name 'deckhand' for unknown berth, got %q", m.GetBerthName("unknown_berth"))
197197+ // Tier name should also fall back
198198+ if m.GetTierName("unknown_tier") != "deckhand" {
199199+ t.Errorf("expected tier name 'deckhand' for unknown tier, got %q", m.GetTierName("unknown_tier"))
200200 }
201201}
202202···220220 configPath := filepath.Join(tmpDir, "quotas.yaml")
221221222222 configContent := `
223223-berths:
223223+tiers:
224224 deckhand:
225225 quota: invalid_size
226226227227defaults:
228228- new_crew_berth: deckhand
228228+ new_crew_tier: deckhand
229229`
230230 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
231231 t.Fatalf("failed to write config: %v", err)
···237237 }
238238}
239239240240-func TestNewManager_NoDefaultBerth(t *testing.T) {
240240+func TestNewManager_NoDefaultTier(t *testing.T) {
241241 tmpDir := t.TempDir()
242242 configPath := filepath.Join(tmpDir, "quotas.yaml")
243243244244 configContent := `
245245-berths:
245245+tiers:
246246 quartermaster:
247247 quota: 50GB
248248249249defaults:
250250- new_crew_berth: nonexistent
250250+ new_crew_tier: nonexistent
251251`
252252 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
253253 t.Fatalf("failed to write config: %v", err)
···258258 t.Fatalf("failed to load config: %v", err)
259259 }
260260261261- // Empty berth key with nonexistent default should return nil (unlimited)
262262- limit := m.GetBerthLimit("")
261261+ // Empty tier key with nonexistent default should return nil (unlimited)
262262+ limit := m.GetTierLimit("")
263263 if limit != nil {
264264- t.Error("expected nil limit when default berth doesn't exist")
264264+ t.Error("expected nil limit when default tier doesn't exist")
265265 }
266266267267- // Explicit berth should still work
268268- limit = m.GetBerthLimit("quartermaster")
267267+ // Explicit tier should still work
268268+ limit = m.GetTierLimit("quartermaster")
269269 if limit == nil {
270270- t.Fatal("expected non-nil limit for quartermaster berth")
270270+ t.Fatal("expected non-nil limit for quartermaster tier")
271271 }
272272 if *limit != 50*1024*1024*1024 {
273273 t.Errorf("expected 50GB limit for quartermaster, got %d", *limit)
+10-10
quotas.yaml.example
···22# Copy this file to quotas.yaml to enable quota enforcement.
33# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
4455-# Berths define quota tiers using nautical crew ranks.
66-# Each berth has a quota limit specified in human-readable format.
55+# Tiers define quota levels using nautical crew ranks.
66+# Each tier has a quota limit specified in human-readable format.
77# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
88-berths:
88+tiers:
99 # Entry-level crew - suitable for new or casual users
1010 deckhand:
1111 quota: 5GB
···1818 quartermaster:
1919 quota: 100GB
20202121- # You can add custom berths with any name:
2121+ # You can add custom tiers with any name:
2222 # unlimited_crew:
2323 # quota: 1TB
24242525defaults:
2626- # Default berth assigned to new crew members who don't have an explicit berth.
2727- # This berth must exist in the berths section above.
2828- new_crew_berth: deckhand
2626+ # Default tier assigned to new crew members who don't have an explicit tier.
2727+ # This tier must exist in the tiers section above.
2828+ new_crew_tier: deckhand
29293030# Notes:
3131-# - The hold captain (owner) always has unlimited quota regardless of berths.
3232-# - Crew members can be assigned a specific berth in their crew record.
3333-# - If a crew member's berth doesn't exist in config, they fall back to the default.
3131+# - The hold captain (owner) always has unlimited quota regardless of tiers.
3232+# - Crew members can be assigned a specific tier in their crew record.
3333+# - If a crew member's tier doesn't exist in config, they fall back to the default.
3434# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
3535# - Quota is checked when pushing manifests (after blobs are already uploaded).