Skip to content

ADR-006: Extract Bates/Stamps to Standalone Service

Status

Proposed

Date

2026-03-19

Context

Bates numbering and confidentiality stamping is a core eDiscovery function that applies unique identifiers and confidentiality designations to every page of every document in a case.

Current State

The stamping system has two completely different execution paths based on nge_enabled?:

NGE Path (already clean):

BatesStampJob → Attachment#stamp_with_template
  → ExhibitNutrientAction#update_nutrient_bates
    → NextpointNutrient.process_bulk_stamp_update (REST API)
      → PSPDFKit text annotations (readOnly, customData.type='bates_stamp')
  → Done (synchronous, no completion polling)

Legacy Path (worker-dependent):

BatesStampJob → Attachment#stamp_with_template
  → create_image_manipulation_job (ProcessingJob record)
    → Workers pick up → ImageManipulationWorker
      → Stamper.bates_and_confidentiality_stamp_image (GD2 pixel rendering)
        → Burns stamp into TIFF/PNG page image
  → BatesStampCompletionJob polls ProcessingJob until all workers finish

Key Files

File Lines Purpose
BatesStampJob ~150 Orchestrator: iterates exhibits, delegates to Attachment#stamp
BatesRemovalJob ~80 Removal: delegates to LabelBatesRemover
BatesStampCompletionJob ~60 Legacy-only: polls worker ProcessingJobs
BatesRemovalCompletionJob ~50 Legacy-only: polls worker ProcessingJobs
ExhibitNutrientAction ~300 NGE: Nutrient API annotation CRUD
Stamper.rb (workers) ~200 Legacy: GD2 pixel stamp rendering
stampStackUtils.js (React) ~400 NGE frontend: PSPDFKit annotation positioning
BatesStampModal.jsx ~200 NGE frontend: bates edit modal
ExhibitStampEditor.jsx ~300 Exhibit stamp template config
BatesManagementService ~100 Single-document bates from viewer
ExhibitStampingService ~100 Legacy exhibit stamp placement

Why Extract?

  1. NGE path is already decoupled — Nutrient API calls are synchronous REST, no worker dependency
  2. No completion polling needed — NGE stamps are instant (Nutrient API), unlike Legacy worker polling
  3. Cross-cutting concern — stamps affect export (documentexporter), exchange (documentexchanger), and review
  4. High page volume — a large case can have millions of pages; independent scaling matters

Decision

Extract NGE bates/confidentiality stamping into a standalone Lambda service that encapsulates all Nutrient annotation operations for stamps.

Architecture

Rails App (or any NGE module)
  ├── SNS: StampRequested
  │     ├── type: bates_stamp | confidentiality_stamp | exhibit_stamp
  │     ├── action: apply | remove | update
  │     ├── exhibit_ids, case_id, pattern, template
  │     │
  │     ▼
  │   SQS Queue → Lambda: StampProcessor
  │     │
  │     ├── core/
  │     │   ├── bates_processor.py      (bates numbering logic)
  │     │   ├── confidentiality_processor.py (confidentiality stamp logic)
  │     │   ├── exhibit_stamp_processor.py   (exhibit stamp logic)
  │     │   └── stamp_layout.py         (font sizing, word wrap, positioning)
  │     │
  │     ├── shell/
  │     │   ├── nutrient_client.py      (PSPDFKit annotation CRUD)
  │     │   ├── db/database.py          (per-case MySQL for stamp_details)
  │     │   └── sns_ops.py              (progress/completion events)
  │     │
  │     ▼
  │   Nutrient API (annotation create/delete)
  │     │
  │     ▼
  │   SNS: StampCompleted → PSM

What Moves

From To What
ExhibitNutrientAction concern core/stamp_layout.py Font sizing, word wrap, annotation positioning
ExhibitNutrientAction concern shell/nutrient_client.py Nutrient API calls (bulk stamp update, delete)
BatesStampJob (NGE branch) core/bates_processor.py Bates numbering iteration, page-count validation
BatesRemovalJob (NGE branch) core/bates_processor.py Bates removal logic
BatesManagementService (NGE) Can call stamp service via SNS Single-doc bates edits

What Stays in Rails

Component Why
BatesStampJob (Legacy branch) Legacy cases still need worker-based pixel stamping
BatesStampCompletionJob Legacy-only polling
Stamper.rb (workers) Legacy GD2 rendering — not worth modernizing
BatesPattern model Pattern storage and next-number management stays in Rails DB
stampStackUtils.js Frontend PSPDFKit SDK annotation positioning stays client-side
ExhibitStampEditor.jsx Frontend stamp template configuration

Migration Strategy

  1. Phase 1: Extract ExhibitNutrientAction stamp methods into a service object within Rails (zero-risk refactoring, same pattern as ADR-005)
  2. Phase 2: Build Lambda service with Nutrient client, deploy alongside Rails
  3. Phase 3: Rails sends StampRequested SNS event instead of calling Nutrient directly
  4. Legacy cases: No change — Legacy path continues through workers until case migration

Consequences

Positive

  • Single owner for stamp logic — one service handles all stamp types consistently
  • documentexporter can call directly — instead of Rails middleware, exporter triggers stamps
  • Independent scaling — stamp operations scale separately from Rails Sidekiq
  • Cleaner Nutrient integration — all Nutrient stamp API calls in one service

Negative

  • Dual path persists — Legacy cases still use workers/GD2 (but this is permanent per ADR)
  • Pattern management complexityBatesPattern (next number) must be thread-safe across Lambda invocations
  • Frontend unchangedstampStackUtils.js still does client-side annotation positioning

Risks

  • Bates number sequencing — bates numbers must be globally sequential per pattern. Lambda concurrency could cause gaps or duplicates. Mitigation: use DB-level SELECT ... FOR UPDATE on BatesPattern for atomic increment.
  • Nutrient API rate limits — bulk stamping millions of pages could hit Nutrient rate limits. Mitigation: SQS-based throttling with configurable concurrency.
Ask the Architecture ×

Ask questions about Nextpoint architecture, patterns, rules, or any module. Powered by Claude Opus 4.6.