Skip to content

saas: DocumentClassifier + PAYG data model - #6460

Merged
ConnorYoh merged 9 commits into
mainfrom
payg-i2-document-classifier
May 29, 2026
Merged

saas: DocumentClassifier + PAYG data model#6460
ConnorYoh merged 9 commits into
mainfrom
payg-i2-document-classifier

Conversation

@ConnorYoh

@ConnorYoh ConnorYoh commented May 27, 2026

Copy link
Copy Markdown
Member

Description of Changes

Two layers — the DocumentClassifier utility plus the full data model for the new billing engine. Nothing wires the entities into application behaviour yet; services and controllers land in follow-up PRs.

Companion PR: Stirling-PDF-SaaS#296 — Supabase migration for the v3 dev branch, schema-equivalent to the Flyway migration in this PR.

1. DocumentClassifier (under payg.docs)

DocumentClassifier computes the doc-unit cost of an uploaded file (or multi-file input) under a PricingPolicy. PDFs read page count via stirling.software.jpdfium.PdfDocument; non-PDFs are bytes-only. Formula: max(ceil(pages / docPagesPerUnit), ceil(bytes / docBytesPerUnit)) clamped to [1, fileUnitCap]. Multi-file is the sum of raw per-file units capped at fileUnitCap × file_count.

Two floors, by design: the classifier returns docUnits with an absolute 1 floor for non-empty input; the policy-level minChargeUnits is intentionally applied later, at process-open time in JobChargeService, per design § 3.4 (unitsForProcess = max(policy.min_charge_units, docUnits)). Documented in the interface + impl javadoc.

Upload bytes are materialised through TempFileManager.createManagedTempFile so jpdfium gets a Path; the temp file auto-deletes on close.

Twelve tests, all in-memory fixtures generated with PDFBox at test time — no committed binary blobs.

2. PAYG data model (under payg.*)

JPA entities, repositories, and a Flyway migration covering the full schema in §6 of the design.

Enums (payg.model):

JobSource, ProcessType, JobStatus, JobStepStatus, ArtifactKind, LedgerEntryType, LedgerBucket, ReferenceType, EntitlementState, FeatureSet, FeatureGate, WalletEngine, CapPeriod, AutoGroupStrategy.

Entities + repositories:

Entity Table Notes
PricingPolicy pricing_policy Promoted from a record. stepLimits is Map<JobSource, Integer> persisted via normalised child table pricing_policy_step_limit. stripePriceIds is Set<String> persisted via pricing_policy_stripe_price — currency comes from stripe.prices via Sync Engine, not stored locally.
ProcessingJob processing_job UUID PK. Tracks lineage window via step_count and last_step_at.
ProcessingJobStep processing_job_step Per-tool-call audit.
JobArtifactHash job_artifact_hash Composite key (job_id, content_hash, kind). content_hash VARCHAR(128) so multiple signature schemes coexist as "type:value" storage keys. Lineage detector queries this.
WalletLedgerEntry wallet_ledger Append-only, signed amount_units. Two unique indexes kill double-posting.
WalletPolicy wallet_policy Per-team engine + cap + degradation rules + lineage strategy. No @Version — admin-only writes (documented in javadoc).
WalletEntitlementSnapshot wallet_entitlement_snapshot Composite key (team_id, user_id); user_id = 0 is the team-wide sentinel. No @Version — full-row recompute via EntitlementService.recompute (documented in javadoc).
PaygShadowCharge payg_shadow_charge Per-job diff while in PAYG_SHADOW engine mode.
PaygTeamExtensions payg_team_extensions Sidecar 1:1 with teams carrying pricing_policy_id (per-team override) + stripe_customer_id. Sidecar pattern (mirrors saas_team_extensions) so OSS Hibernate ddl-auto never sees PAYG columns on teams.

Column adds:

  • team_memberships.cap_units (optional per-member sub-cap)

Width split (intentional, documented in V11): per-row deltas (wallet_ledger.amount_units, processing_job.charged_units) are INTEGER because no single charge realistically approaches 2B units. Cap and period-rollup columns (team_memberships.cap_units, wallet_policy.cap_units, wallet_entitlement_snapshot.period_spend_units / period_cap_units) are BIGINT because they accumulate across a billing period and admins may legitimately set headroom-cap values into the millions.

JPA wiring: SaasJpaConfig was updated to include stirling.software.saas.payg.repository in @EnableJpaRepositories.basePackages and stirling.software.saas.payg in @EntityScan (covers payg.policy / payg.job / payg.wallet / payg.entitlement / payg.shadow recursively). New SaasJpaConfigScanTest reads the annotations reflectively and asserts every expected package is wired — catches the next time someone adds a new sub-package without updating the scan paths.

Migration: V11__saas_payg_model.sql (purely additive). Schema-equivalent to the Supabase migration in the companion PR — including the VARCHAR(128) content_hash width that's needed for the multi-signature-scheme storage encoding the lineage layer uses.

3. Smoke tests

PaygEntitiesSmokeTest exercises each entity via the no-arg ctor JPA requires, plus getter/setter round-trips and composite-key equality — catches Lombok/annotation regressions without needing a database. Real-DB integration coverage lands alongside the services that consume each entity.

Why this is safe to land now

  • All schema changes are additive — no existing rows modified, no columns dropped.
  • The entities are not yet referenced from any production code path; they exist for the next PRs to build on.
  • The v3 Supabase dev branch picks up the schema via the companion PR; the main repo's Flyway migration applies the same shape when an instance boots against a freshly-migrated v3 database.

Open decisions made

  • Step-limits keyed by JobSource rather than by ProcessType. Captures the "self-hosted gets a different knob" framing in earlier feedback. Trivially overridable per pricing policy version.
  • Step limits + Stripe price IDs normalised into child tables rather than JSONB on pricing_policy (per Connor's review on add fileInput widget to multiSelect #296). Typed columns, queryable directly, no JSON parsing.
  • Currency dropped from pricing_policy_stripe_price — it lives on stripe.prices.currency and is resolved via Sync Engine. App is currency-blind.

Rollback

Straight git revert on this PR. The Supabase migration in #296 is additive and can be left in place safely — the running app ignores tables it doesn't reference.


Checklist

Computes the doc-unit cost of an uploaded file (or multi-file input) under a
PricingPolicy. PDF page counts via PDFBox; non-PDFs are bytes-only. Result is
max(ceil(pages/perPage), ceil(bytes/perBytes)) clamped to [1, fileUnitCap].

Pure utility — no Spring beans wired into it yet, no schema, no Supabase
dependency. Foundational for later PRs that compute charges per upload.

PricingPolicy lives as a minimal record now (just the fields the classifier
reads). A future entity can replace it with the same field surface plus
persistence + lifecycle metadata.

Tests cover: single-page PDF, multi-page PDF (page axis dominates), large
non-PDF (byte axis dominates), file-unit-cap clamp, empty file, malformed
PDF (graceful bytes-only fallback), encrypted PDF, missing content type,
extension-based PDF detection, multi-file aggregation, multi-file cap.
Fixtures generated programmatically via PDFBox at test time — no committed
binary blobs.
@dosubot dosubot Bot added size:L This PR changes 100-499 lines ignoring generated files. enhancement New feature or request labels May 27, 2026
@stirlingbot stirlingbot Bot removed the enhancement New feature or request label May 27, 2026

@Frooodle Frooodle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A AI review also said "Blocking (1 thing): Fix the dead group-cap in multi-file classify. The Math.min(groupCap, unitsSum) can never bind because each file is already clamped to fileUnitCap before summing - so either delete it (if per-file capping is intended) or rewrite to sum raw per-file units so the group cap actually does something. Either way, replace multiFile_isCappedByFileUnitCapTimesFileCount with a test that genuinely exercises the cap (today it passes even with the cap removed)."

- Drop @Profile("saas") — the classifier is stateless utility code, so any
  deployment can use it without re-configuring profiles.
- Switch the page-count read from PDFBox Loader.loadPDF to jpdfium
  (PdfDocument.open(Path).pageCount()), which is the codebase standard for
  cheap PDF metadata reads.
- Materialise uploads through TempFileManager so jpdfium gets a Path; the
  TempFile is AutoCloseable and auto-deletes on close.
- Fix the dead group cap in classify(List). The previous implementation
  clamped per file before summing, so Math.min(groupCap, unitsSum) was a
  no-op. Now: compute raw per-file units, sum, then clamp the sum at
  fileUnitCap × file_count. The single-file classify(MultipartFile) path
  keeps its own clamp.
- Replace multiFile_isCappedByFileUnitCapTimesFileCount (passed by accident
  via per-file clamping) with multiFile_groupCapBindsOnSumOfRawUnits — uses
  asymmetric file sizes so the only way to hit the expected result is via
  the group cap on the raw sum. Per-file clamping would produce a
  detectably-different number.
- Add singleFileFileUnitCap_clampsExtremelyLargeInputs to keep coverage of
  the single-file clamp behaviour explicit.
- Test policies now use KiB-scale byte units so allocations stay small.
@ConnorYoh

Copy link
Copy Markdown
Member Author

Addressed the AI reviewer's blocking finding too: the group cap in classify(List) was indeed dead because per-file clamping ran before the sum. Fixed by computing raw per-file units (no clamp), summing, then clamping the sum at fileUnitCap × file_count. The single-file classify(MultipartFile) path keeps its own per-file clamp.

Replaced the previous multiFile_isCappedByFileUnitCapTimesFileCount test (which passed by accident via per-file clamping) with multiFile_groupCapBindsOnSumOfRawUnits — uses asymmetric file sizes (50 raw units + 1 raw unit, cap 25 × 2 = 50) so the only way to land on 50 is via the group cap on the raw sum. The old buggy implementation would produce 26 instead.

Also added singleFileFileUnitCap_clampsExtremelyLargeInputs to keep explicit coverage of the single-file clamp behaviour now that the multi-file path no longer exercises it.

All three inline comments addressed in 04d44d1. PTAL.

Bundles the full data foundation for the new billing model. Nothing wires
the entities into application behaviour yet; services and controllers land
in follow-up PRs.

New packages under stirling.software.saas.payg:
  - model: 13 enums (JobSource, ProcessType, JobStatus, JobStepStatus,
    ArtifactKind, LedgerEntryType, LedgerBucket, ReferenceType,
    EntitlementState, FeatureSet, FeatureGate, WalletEngine, CapPeriod,
    AutoGroupStrategy).
  - policy: PricingPolicy promoted from a record to a JPA entity.
    Step limits are keyed by JobSource (WEB/API/PIPELINE/DESKTOP) — same
    motivation as the "self-hosted gets a different knob" point: a single
    knob per caller surface lets self-hosted carry a different ceiling
    without bolting a new column on.
  - job: ProcessingJob, ProcessingJobStep, JobArtifactHash (composite key).
  - wallet: WalletLedgerEntry, WalletPolicy.
  - entitlement: WalletEntitlementSnapshot (composite key; user_id = 0
    is the team-wide sentinel since Postgres treats NULL as not-equal-to-NULL
    in unique constraints).
  - shadow: PaygShadowCharge.
  - repository: one repo per entity, plus a lineage-lookup query on
    JobArtifactHashRepository and period-spend rollups on WalletLedgerRepository.

V11__saas_payg_model.sql creates all eight new tables and adds three
columns to existing tables (teams.pricing_policy_id,
teams.stripe_customer_id, team_memberships.cap_units). Purely additive.

DocumentClassifier updated to use bean-style getters on the new
PricingPolicy entity. A convenience 4-arg constructor preserves the
record-style call shape the classifier and its tests already use.

PaygEntitiesSmokeTest exercises each entity via the no-arg ctor that JPA
requires + a few getter/setter pairs + composite-key equality, catching
Lombok/annotation regressions without needing a database.
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines ignoring generated files. and removed size:L This PR changes 100-499 lines ignoring generated files. labels May 28, 2026
@ConnorYoh ConnorYoh changed the title saas: add DocumentClassifier for PAYG unit-cost calculation saas: DocumentClassifier + PAYG data model May 28, 2026
@ConnorYoh

Copy link
Copy Markdown
Member Author

Expanded the scope per discussion — now includes the full PAYG data model (8 new tables + column adds across teams + team_memberships) on top of the DocumentClassifier work.

Companion Supabase migration: https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/296 (targeting the v3 dev branch).

Merge order: SaaS PR first (so v3 Supabase has the schema), then this one. Both are purely additive so either order is safe in dev, but landing the schema first lets the application boot cleanly against a fresh v3 database.

Open design decision: step limits are keyed by JobSource (WEB/API/PIPELINE/DESKTOP) on pricing_policy.step_limits rather than by process_type. Lets self-hosted carry a different ceiling without a new column. Easy to revisit by editing the JSONB shape.

- Fix FK column name: teams(id) → teams(team_id) across all eight new
  tables. The Supabase teams PK is team_id; the previous version failed
  on the v3 preview branch with SQLSTATE 42703.
- Move teams.pricing_policy_id and teams.stripe_customer_id to a
  payg_team_extensions sidecar table (mirrors saas_team_extensions). PAYG
  fields no longer touch the shared teams table, so OSS Hibernate ddl-auto
  against the proprietary Team entity stays unaffected.
- Add PaygTeamExtensions JPA entity + repository over the new sidecar.
- Add capUnits field to TeamMembership entity (column already in V11).
- Rename JobSource.DESKTOP → DESKTOP_APP and add a class-level javadoc
  clarifying the enum is caller surface only; deployment context (SaaS
  vs self-hosted) is encoded at the team / pricing_policy level.
- Fix WalletEntitlementSnapshot class doc — the team-wide row uses
  user_id = 0 (not NULL); the previous wording contradicted the impl.

Tests: :saas:test all green.
git add -A in the previous commit swept up untracked build output from
frontend/src-tauri/provisioner/target/ that has been sitting in this
working tree across the session. These are Cargo build artifacts that
should never be in the repo. Removing them with git rm --cached so the
files stay on disk locally but leave version control.
@stirlingbot stirlingbot Bot added Front End Issues or pull requests related to front-end development and removed Front End Issues or pull requests related to front-end development labels May 28, 2026
ConnorYoh added 2 commits May 28, 2026 13:17
Both PR review feedback from Connor:

1. step_limits and stripe_price_ids were JSONB columns on pricing_policy.
   That's structured data with a small known shape — better as typed
   child tables. Replaced:

   - pricing_policy_step_limit (policy_id, job_source, step_limit)
   - pricing_policy_stripe_price (policy_id, currency, stripe_price_id)

   Java-side stays Map<JobSource, Integer> and Map<String, String> via
   JPA @ElementCollection + @CollectionTable, so callers don't change.
   No JSON parsing, queryable directly, FK-cascaded on policy delete.

2. wallet_policy.cap_source_currency duplicated stripe.customers.currency.
   Dropped the column. cap_source_money stays (preserves the customer's
   money intent across Stripe price changes); currency comes from the
   team's Stripe customer at recompute time.

Counterpart in Stirling-PDF-SaaS#296.
Same Stripe-as-source-of-truth principle as the cap_source_currency drop:
currency lives on stripe.prices.currency, not on our row.

- Migration: pricing_policy_stripe_price (policy_id, stripe_price_id),
  PK (policy_id, stripe_price_id). No currency column.
- Entity: PricingPolicy.stripePriceIds becomes Set<String> (was
  Map<String, String>). Currency-aware lookup happens via
  stripe.prices.currency through Sync Engine.
- Smoke test updated to match the Set shape.

Trade-off: subscription-creation logic that picks the right Price for a
customer's currency will need stripe.prices available (PR-T4 Sync Engine).
The hot path (meter event reporting) doesn't read this table at all, so
the dependency only matters at subscription-creation time.

Counterpart in Stirling-PDF-SaaS#296.
jbrunton96
jbrunton96 previously approved these changes May 28, 2026
…iene

Six concerns from the 2026-05-29 review, all in one commit. Defers concern
#7 (ddl-auto=update with Flyway) to a separate cleanup PR — it's a
pre-existing setting whose flip carries OSS-entity-validation risk that
needs its own focused change.

#1 (HIGH) content_hash widened to VARCHAR(128) at the source
  - V11: CHAR(64) → VARCHAR(128) (with comment explaining the type:value
    storage key encoding)
  - Entity JobArtifactHashId.contentHash: length=64 → 128 + javadoc
  - Folds the change from #6464's stacked "schema fix" commit down here so
    #6460 is internally consistent and the Supabase #296 migration's
    "counterpart to the V11 widen" stops being aspirational. After this lands,
    the corresponding edit on #6464 becomes a no-op and gets squashed during
    rebase.

#2 (HIGH) Restore @Profile("saas") on DefaultDocumentClassifier
  - The earlier removal was based on a hypothetical "future paid deployment
    without the saas profile". Concrete consistency with the 38 other
    @Profile("saas") beans in the module wins over speculative flexibility.
  - Class javadoc now documents the choice and the path to broadening
    (@Profile({"saas", "selfhosted-payg"})) when PR-X1 lands.

#3 (MEDIUM, but materially worse than the review noted) Wire payg.* into the
JPA scan paths
  - SaasJpaConfig was scanning saas.repository / .billing.repository /
    .ai.repository for repos and saas.model / .billing.model / .ai.model for
    entities — NOT saas.payg.*. The entire PAYG layer was inert: repos
    weren't beans, entities weren't managed types, every @Autowired
    PaygSomethingRepository would have failed at startup. Mockito-based unit
    tests never noticed.
  - Knock-on: my own stacked PR #6469 (PricingPolicyService) wouldn't have
    started either. Fixing here fixes both.
  - Added saas.payg.repository to @EnableJpaRepositories.basePackages and
    saas.payg (covers payg.policy / payg.job / payg.wallet / payg.entitlement
    / payg.shadow recursively) to @EntityScan.
  - New SaasJpaConfigScanTest reads the annotations via reflection and
    asserts every expected package is present — catches the next time
    someone adds a payg.X sub-package without wiring the scan. Reflection
    rather than @DataJpaTest because the production schema uses partial
    unique indexes that H2 doesn't fully support; standing up Testcontainers
    for one guard test is disproportionate.

#4 (MEDIUM) Document the minChargeUnits flow
  - Per design § 3.4 the charge formula is unitsForProcess =
    max(policy.min_charge_units, docUnits), applied at process-open time
    in JobChargeService — NOT at classify time. The classifier's hardcoded
    floor of 1 is a separate "non-empty input → ≥1 unit" invariant.
  - Renamed the literal 1 to MIN_UNITS_PER_NONEMPTY_FILE constant with
    javadoc.
  - Added javadoc paragraphs on both the DocumentClassifier interface and
    DefaultDocumentClassifier explaining the two-floor design so the next
    reader doesn't conclude minChargeUnits is silently inert.

#6 (LOW) Math.toIntExact on the multi-file group cap cast
  - Replaces (int) Math.max(1L, Math.min(groupCap, rawUnitsSum)) with
    Math.toIntExact(...). Theoretical (HTTP body limits make 2.15M files
    impossible) but matches the saturatedAdd care taken everywhere else.
    Single-file path got the same treatment.

#8 (LOW) Document the @Version asymmetry + INTEGER-vs-BIGINT widths
  - WalletPolicy and WalletEntitlementSnapshot now carry javadoc explaining
    why no @Version: admin-only writes (wallet_policy) or full-row
    recomputation (entitlement snapshot) — no read-modify-write race exists.
  - V11 wallet_ledger section now carries a comment block explaining the
    width split: amount_units INTEGER (per-row delta, always small), but
    cap_units / period_spend_units / period_cap_units BIGINT (accumulate
    across a period, headroom matters).

#5 (LOW) — PR description drift — handled separately via gh pr edit.
#7 (LOW) — ddl-auto=update with Flyway — deferred to its own PR.

Full :saas:test BUILD SUCCESSFUL with the new SaasJpaConfigScanTest
passing 2/2.
@ConnorYoh

ConnorYoh commented May 29, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review — all eight concerns validated and addressed in commit 16113f0b3. Per-concern response:

Fixed in this commit

  • ** 1 (HIGH) content_hash width.** Widened to VARCHAR(128) at the source: V11 + JobArtifactHashId.contentHash length=128, with a SQL comment + entity javadoc explaining the "type:value" storage key encoding the lineage layer needs. Folds the fix from the stacked PAYG: hash-lineage detection primitives (modular extractor / store / detector) #6464 down here so this PR is internally consistent and the Supabase add fileInput widget to multiSelect #296 "counterpart to the V11 widen" comment is finally accurate. (After this lands, the corresponding edit on PAYG: hash-lineage detection primitives (modular extractor / store / detector) #6464 becomes a no-op during rebase.)

  • ** 3 (MEDIUM, materially worse than the review noted) JPA scan.** You're right — and worse, my own stacked PAYG: PricingPolicyService + admin REST + 30s read cache #6469 (PricingPolicyService) wouldn't have started in production either, for the same reason. SaasJpaConfig now scans stirling.software.saas.payg.repository for repos and stirling.software.saas.payg for entities (covers payg.policy / payg.job / payg.wallet / payg.entitlement / payg.shadow recursively). Added SaasJpaConfigScanTest — reflection-based, asserts the expected packages are wired so this can't drift silently again. Reflection rather than @DataJpaTest because V11 uses partial unique indexes that H2 doesn't fully support; standing up Testcontainers for one guard test is disproportionate. Real-DB smoke coverage will land alongside @SpringBootTest integration tests for the PAYG services.

  • ** 4 (MEDIUM) minChargeUnits flow documented.** Per design § 3.4 the formula is unitsForProcess = max(policy.min_charge_units, docUnits) applied at process-open time in JobChargeService, not at classify time. The classifier's hardcoded floor of 1 is a separate "non-empty input → ≥1 unit" invariant. Renamed to MIN_UNITS_PER_NONEMPTY_FILE constant with javadoc; added paragraphs on both DocumentClassifier interface and DefaultDocumentClassifier impl explaining the two-floor design.

  • ** 5 (LOW) PR description drift.** Just rewrote the description to match the actual schema — normalised child tables, sidecar pattern, dropped currency column, JPA scan note.

  • ** 6 (LOW) Math.toIntExact.** Both single-file and multi-file classify paths now use Math.toIntExact(...). Theoretical given HTTP body limits, but matches the saturatedAdd care elsewhere.

  • ** 8 (LOW) @Version + width split documented.** WalletPolicy and WalletEntitlementSnapshot now carry javadoc explaining why no @Version (admin-only writes / full-row recompute — no read-modify-write race). V11 wallet_ledger section has a comment block explaining the INTEGER per-row vs BIGINT rollup split.

Negotiated and accepted

  • ** 2 (HIGH) @Profile("saas") restored on DefaultDocumentClassifier.** You're right that the original removal left a single un-gated bean in a module where the 38 others are all profile-gated; the "future paid deployment without saas profile" justification was hypothetical and unmaterialised. Concrete consistency wins. Class javadoc documents the call and the broadening path (@Profile({"saas", "selfhosted-payg"})) when PR-X1 lands.

Deferred to a separate cleanup PR

  • ** 7 (LOW) ddl-auto=update vs Flyway.** Real concern — validate is the safer setting with a Flyway-managed schema. But this property is pre-existing (not introduced by this PR), and flipping it carries a meaningful risk: OSS entities that have quietly relied on update to auto-materialise their schema would cause startup failures. That deserves its own focused PR with a separate test sweep across all OSS entities. Tracking as a follow-up.

Tests + build

ENABLE_SAAS=true ./gradlew :saas:test BUILD SUCCESSFUL. New SaasJpaConfigScanTest 2/2 pass. Coverage stays in line with prior runs (BRANCH passes the gate; LINE/INSTRUCTION are below the line — same as before this PR — driven by the inert entity/repo classes that have no exercise yet, which is expected for groundwork PRs).

Knock-ons to stacked PRs

Both will be rebased onto this commit next.

The review-response commit landed several oversized javadoc paragraphs and
SQL comment blocks. Trim each to the essential what/why; drop the inline
"see <design-doc-section>" / "PR-X1" markers — code shouldn't carry roadmap
references.

Net: ~70 fewer lines of comment, same information density on the points
that matter.
@ConnorYoh
ConnorYoh added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit 83ea07e May 29, 2026
36 checks passed
@ConnorYoh
ConnorYoh deleted the payg-i2-document-classifier branch May 29, 2026 12:06
ConnorYoh added a commit that referenced this pull request May 29, 2026
PR-I1 (service half). Built off #6460 — sibling of #6464 (lineage primitives),
both stacked on payg-i2-document-classifier so they can review independently.

Lookup precedence:
  - PaygTeamExtensions.pricingPolicyId override → load that policy
  - else load the pricing_policy row with is_default = TRUE
  - if the override points at a deleted row, fall back to default (logs a warn —
    safety net for racing deletes)

Cache:
  - 30s Caffeine, keyed by teamId, max 10k entries — correctness floor.
  - Invalidated on PolicyChangedEvent from any source (admin mutation here, or
    cross-instance via the Postgres LISTEN runner).
  - Admin reads bypass the cache (getEffectivePolicyUncached) so admins always
    see their own writes.

Writes (service layer, transactional, fire-after-commit event):
  - create(draft) — rejects pre-set policy_id or is_default=true (promotion
    must go through setDefault so the partial unique idx is freed first).
  - setDefault(id) — clearDefaultFlag + flip; idempotent no-op if already
    default (no event fired in that case).
  - setTeamOverride(teamId, policyId|null) — validate policy exists before save.

Admin REST surface — /api/v1/admin/payg/...
  - GET  /policies, GET /policies/{id}, POST /policies
  - POST /policies/{id}/set-default
  - PUT  /teams/{teamId}/policy-override
  - GET  /teams/{teamId}/effective-policy  (bypasses cache)
  All gated by @PreAuthorize("hasRole('ADMIN')"). Validation errors return 400,
  unknown rows return 404.

LISTEN/NOTIFY runner (PolicyChangeListener):
  - Opens a dedicated raw JDBC connection via DriverManager (not HikariCP —
    Hikari would evict idle LISTEN connections) and polls getNotifications()
    on a daemon thread.
  - Reconnects with 5s backoff on SQLException; the 30s TTL is the correctness
    floor during outages.
  - Disabled by setting payg.policy.listen.enabled=false (default on).
  - PgJDBC moved to compileOnly on :saas; the runtime artifact is still
    bundled via :proprietary.

V11 migration: seed the V1 default policy (25 pages/unit, 5 MiB/unit,
min charge 1, file cap 1000) + step limits per JobSource (WEB/API/DESKTOP_APP
10, PIPELINE 20). Idempotent — only inserts when no default exists.

Tests: 17 PricingPolicyServiceTest + 14 PricingPolicyAdminControllerTest, all
passing. Coverage moves 10.87% → 12.40% LINE and 11.89% → 13.85% INSTRUCTION.

Counterpart Supabase migration (NOTIFY trigger function + per-table triggers
on pricing_policy*, payg_team_extensions overrides, wallet_policy; plus the
default-policy seed) ships on a separate SaaS PR on payg-i1-pricing-policy-service.
ConnorYoh added a commit that referenced this pull request May 29, 2026
V11 has shipped to main via #6460; modifying it now would change its Flyway
checksum and break every deployment that already ran V11. The seed (V1
default pricing policy row + per-JobSource step limits) lands in
V12__seed_default_payg_policy.sql instead — purely additive, idempotent
(WHERE NOT EXISTS), no schema change.

Also drops a stray "PR-C5" roadmap marker that survived the trim pass.
ConnorYoh added a commit that referenced this pull request May 29, 2026
Three orthogonal interfaces, each with one production impl. Designed so the
hash algorithm, the storage backend, and the matching policy can each be
swapped independently without changes elsewhere.

LineageSignatureExtractor — given a Path, returns Set<LineageSignature>.
  File-based (not stream-based) so a future PDF-aware extractor can open the
  same file with jpdfium / PDFBox and pull /ID[0] or a content-stream hash
  alongside the byte hash. Multiple extractors compose at the detector
  layer via Spring auto-wiring; the detector unions their results.
  Default impl: ByteHashSignatureExtractor — SHA-256 of file bytes via a
  64 KiB-buffered DigestInputStream. Hardware-accelerated by the JVM.

JobLineageStore — record(jobId, signatures, kind) /
  findOpenJobForSignatures(userId, candidates, window) / pruneOlderThan.
  Knows nothing about storage technology. Production impl JpaJobLineageStore
  backs onto job_artifact_hash via a single joined query. A future
  RedisJobLineageStore is a drop-in.

HashLineageDetector — high-level API. Delegates extraction to the
  configured extractors and storage to the store. Reads
  payg.lineage.workflow-window (default PT5M) from config. Future hook:
  when wallet_policy.auto_group_strategy lookup lands, the detect() method
  short-circuits to empty when the team has opted out.

Schema: content_hash widened from CHAR(64) to VARCHAR(128) so non-SHA-256
signatures fit. Storage form is "type:value" — sha256:hexdigest,
pdf-id:uuid, etc. coexist on the same column.

Tests: InMemoryJobLineageStore + FakeFileSignatureExtractor under
src/test exercise the detector end-to-end without a database. Covers
same-user, within-window, status=OPEN, multi-signature, most-recent-wins,
record+detect round-trip, and extractor-failure isolation.

Stacked PR: branched off payg-i2-document-classifier because the JPA
store + JobArtifactHash entity depend on PR #6460's data model. Once that
merges, this PR rebases to target main.
brosequist pushed a commit to brosequist/Stirling-PDF that referenced this pull request May 29, 2026
…ols#6469)

## What this is

PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data
model from Stirling-Tools#6460 — answers "what pricing policy applies to this team
right now?" with a fast cache and an admin write surface.

## Scope

| Piece | Where |
|---|---|
| `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s
Caffeine cache + admin write paths |
`app/saas/.../payg/policy/PricingPolicyService.java` |
| `PolicyChangedEvent` — published after admin writes for in-process
cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java`
|
| Admin REST — list / get / create / set-default / set team override /
get effective |
`app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` +
DTOs |
| `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for
set-default | repository update |
| `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going
stale (carried over from the Stirling-Tools#6460 review concern) | new test |
| V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit,
per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` |

## Lookup precedence

1. `PaygTeamExtensions.pricingPolicyId` set → return that policy
2. Else return the `pricing_policy` row with `is_default = TRUE`
3. Override row points at a deleted policy → log warn, fall back to
default (safety net for racing deletes)
4. No default → `IllegalStateException` (V12 seed guarantees one exists)

## Cache behaviour

- 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`.
- **Single correctness model: the TTL.** Cross-instance propagation is
at-most-30-seconds. The writer instance sees its own change immediately
via the `PolicyChangedEvent` after-commit publish. Other instances pick
it up on the next TTL expiry.
- Admin reads use `getEffectivePolicyUncached` so admins always see
their own write straight back.

**Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a
Postgres `LISTEN policy_changed` runner so cross-instance propagation
was instant. Dropped — admin policy changes are events-per-week and the
30s TTL is already the correctness floor; the listener was ~250 lines of
nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect
loop, lock-protected connection lifecycle) for a use case that isn't on
the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with
three concrete triggers that would justify reintroducing it (aggressive
cap enforcement, Redis landing for other reasons, real-time admin UI).

## Writes — transactional, fire `PolicyChangedEvent` after commit

- `create(draft)` — rejects pre-set `policy_id` or `is_default=true`
(promotion must go through `setDefault` so the partial unique idx is
freed first).
- `setDefault(id)` — atomically clears the existing default via
`clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if
the row is already default.
- `setTeamOverride(teamId, policyId | null)` — validates the policy
exists before save; `null` clears the override.

`publishOnCommit` uses `TransactionSynchronizationManager.afterCommit`
so listeners never see pre-commit state. Outside a transaction (test
paths) falls through to immediate publish.

## Admin REST surface — `/api/v1/admin/payg/...`

All endpoints `@PreAuthorize("hasRole('ADMIN')")`:

- `GET  /policies` — list all
- `GET  /policies/{id}` — read one
- `POST /policies` — create new (non-default)
- `POST /policies/{id}/set-default` — atomic promote
- `PUT /teams/{teamId}/policy-override` — set or clear per-team override
- `GET  /teams/{teamId}/effective-policy` — cache-bypassing live read

Validation errors → 400, unknown rows → 404.

## Counterpart Supabase PR


[`Stirling-PDF-SaaS#298`](Stirling-Tools/Stirling-PDF-SaaS#298)
— seeds the same V1 default policy on the Supabase side via
`20260528000002_payg_seed_default_policy.sql`.

## Tests

- 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss,
invalidation on event, mutation paths publishing event, error cases.
- 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path
+ error mapping, DTO defensive-copy invariant.
- 2 × `SaasJpaConfigScanTest` — reflection-based guard that
`payg.repository` is in `@EnableJpaRepositories` and `payg` is in
`@EntityScan`. Without this, new sub-packages can silently fail to wire
at runtime — same class of bug that the Stirling-Tools#6460 review caught.

Full `:saas:test` BUILD SUCCESSFUL.

## Design doc

`notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the
schema half landed in Stirling-Tools#6460). §9 carries the 30s-TTL trade-off note.
ConnorYoh added a commit that referenced this pull request Jun 2, 2026
Three orthogonal interfaces, each with one production impl. Designed so the
hash algorithm, the storage backend, and the matching policy can each be
swapped independently without changes elsewhere.

LineageSignatureExtractor — given a Path, returns Set<LineageSignature>.
  File-based (not stream-based) so a future PDF-aware extractor can open the
  same file with jpdfium / PDFBox and pull /ID[0] or a content-stream hash
  alongside the byte hash. Multiple extractors compose at the detector
  layer via Spring auto-wiring; the detector unions their results.
  Default impl: ByteHashSignatureExtractor — SHA-256 of file bytes via a
  64 KiB-buffered DigestInputStream. Hardware-accelerated by the JVM.

JobLineageStore — record(jobId, signatures, kind) /
  findOpenJobForSignatures(userId, candidates, window) / pruneOlderThan.
  Knows nothing about storage technology. Production impl JpaJobLineageStore
  backs onto job_artifact_hash via a single joined query. A future
  RedisJobLineageStore is a drop-in.

HashLineageDetector — high-level API. Delegates extraction to the
  configured extractors and storage to the store. Reads
  payg.lineage.workflow-window (default PT5M) from config. Future hook:
  when wallet_policy.auto_group_strategy lookup lands, the detect() method
  short-circuits to empty when the team has opted out.

Schema: content_hash widened from CHAR(64) to VARCHAR(128) so non-SHA-256
signatures fit. Storage form is "type:value" — sha256:hexdigest,
pdf-id:uuid, etc. coexist on the same column.

Tests: InMemoryJobLineageStore + FakeFileSignatureExtractor under
src/test exercise the detector end-to-end without a database. Covers
same-user, within-window, status=OPEN, multi-signature, most-recent-wins,
record+detect round-trip, and extractor-failure isolation.

Stacked PR: branched off payg-i2-document-classifier because the JPA
store + JobArtifactHash entity depend on PR #6460's data model. Once that
merges, this PR rebases to target main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants