···598598 "error", msg.Error)
599599}
600600601601-func truncateError(s string, maxLen int) string {
602602- if len(s) <= maxLen {
603603- return s
604604- }
605605- return s[:maxLen]
606606-}
607607-608601// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner.
609602// Collects all pending rows first, closes cursor, then assigns and dispatches
610603// to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
+30
pkg/hold/quota/config.go
···2424type TierConfig struct {
2525 // Human-readable size limit, e.g. "5GB", "50GB", "1TB".
2626 Quota string `yaml:"quota" comment:"Storage quota limit (e.g. \"5GB\", \"50GB\", \"1TB\")."`
2727+2828+ // Whether pushing triggers an immediate vulnerability scan.
2929+ ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."`
2730}
28312932// DefaultsConfig represents default settings.
···163166 return ""
164167 }
165168 return m.config.Defaults.NewCrewTier
169169+}
170170+171171+// ScanOnPush returns whether scan-on-push is enabled for a tier.
172172+// Follows the same fallback logic as GetTierLimit:
173173+// 1. If quotas disabled → false (caller decides default)
174174+// 2. If tierKey provided and found → that tier's ScanOnPush
175175+// 3. If tierKey not found or empty → use defaults.new_crew_tier
176176+// 4. If default tier not found → false
177177+func (m *Manager) ScanOnPush(tierKey string) bool {
178178+ if !m.IsEnabled() {
179179+ return false
180180+ }
181181+182182+ if tierKey != "" {
183183+ if tier, ok := m.config.Tiers[tierKey]; ok {
184184+ return tier.ScanOnPush
185185+ }
186186+ }
187187+188188+ // Fall back to default tier
189189+ if m.config.Defaults.NewCrewTier != "" {
190190+ if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok {
191191+ return tier.ScanOnPush
192192+ }
193193+ }
194194+195195+ return false
166196}
167197168198// TierCount returns the number of configured tiers
+96
pkg/hold/quota/config_test.go
···294294 }
295295}
296296297297+func TestScanOnPush_Disabled(t *testing.T) {
298298+ // Quotas disabled → ScanOnPush returns false
299299+ m, err := NewManagerFromConfig(nil)
300300+ if err != nil {
301301+ t.Fatalf("unexpected error: %v", err)
302302+ }
303303+ if m.ScanOnPush("bosun") {
304304+ t.Error("expected false when quotas disabled")
305305+ }
306306+}
307307+308308+func TestScanOnPush_ExplicitTier(t *testing.T) {
309309+ cfg := &Config{
310310+ Tiers: map[string]TierConfig{
311311+ "deckhand": {Quota: "5GB", ScanOnPush: false},
312312+ "bosun": {Quota: "50GB", ScanOnPush: true},
313313+ "quartermaster": {Quota: "100GB", ScanOnPush: true},
314314+ },
315315+ Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
316316+ }
317317+ m, err := NewManagerFromConfig(cfg)
318318+ if err != nil {
319319+ t.Fatalf("unexpected error: %v", err)
320320+ }
321321+322322+ if m.ScanOnPush("deckhand") {
323323+ t.Error("expected false for deckhand")
324324+ }
325325+ if !m.ScanOnPush("bosun") {
326326+ t.Error("expected true for bosun")
327327+ }
328328+ if !m.ScanOnPush("quartermaster") {
329329+ t.Error("expected true for quartermaster")
330330+ }
331331+}
332332+333333+func TestScanOnPush_FallbackToDefault(t *testing.T) {
334334+ cfg := &Config{
335335+ Tiers: map[string]TierConfig{
336336+ "deckhand": {Quota: "5GB", ScanOnPush: false},
337337+ "bosun": {Quota: "50GB", ScanOnPush: true},
338338+ },
339339+ Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
340340+ }
341341+ m, err := NewManagerFromConfig(cfg)
342342+ if err != nil {
343343+ t.Fatalf("unexpected error: %v", err)
344344+ }
345345+346346+ // Unknown tier falls back to default (deckhand) which is false
347347+ if m.ScanOnPush("unknown") {
348348+ t.Error("expected false for unknown tier (fallback to deckhand)")
349349+ }
350350+351351+ // Empty tier also falls back
352352+ if m.ScanOnPush("") {
353353+ t.Error("expected false for empty tier (fallback to deckhand)")
354354+ }
355355+}
356356+357357+func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) {
358358+ cfg := &Config{
359359+ Tiers: map[string]TierConfig{
360360+ "deckhand": {Quota: "5GB", ScanOnPush: true},
361361+ },
362362+ Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
363363+ }
364364+ m, err := NewManagerFromConfig(cfg)
365365+ if err != nil {
366366+ t.Fatalf("unexpected error: %v", err)
367367+ }
368368+369369+ // Unknown tier falls back to default (deckhand) which is true
370370+ if !m.ScanOnPush("unknown") {
371371+ t.Error("expected true for unknown tier (fallback to deckhand with scan_on_push: true)")
372372+ }
373373+}
374374+375375+func TestScanOnPush_ZeroValue(t *testing.T) {
376376+ // When scan_on_push is omitted from config, Go zero value = false
377377+ cfg := &Config{
378378+ Tiers: map[string]TierConfig{
379379+ "deckhand": {Quota: "5GB"}, // ScanOnPush not set
380380+ },
381381+ Defaults: DefaultsConfig{NewCrewTier: "deckhand"},
382382+ }
383383+ m, err := NewManagerFromConfig(cfg)
384384+ if err != nil {
385385+ t.Fatalf("unexpected error: %v", err)
386386+ }
387387+388388+ if m.ScanOnPush("deckhand") {
389389+ t.Error("expected false when scan_on_push is omitted (zero value)")
390390+ }
391391+}
392392+297393func TestNewManager_NoDefaultTier(t *testing.T) {
298394 tmpDir := t.TempDir()
299395 configPath := filepath.Join(tmpDir, "quotas.yaml")