diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java
new file mode 100644
index 000000000..86e6e4ab4
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java
@@ -0,0 +1,61 @@
+package stirling.software.saas.payg.lineage;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Set;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+/**
+ * SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
+ * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
+ * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
+ *
+ *
Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
+ * PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
+ * at the detector layer, no interaction needed here.
+ */
+@Component
+@Profile("saas")
+public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
+
+ private static final String ALGORITHM = "SHA-256";
+ private static final String SIGNATURE_TYPE = "sha256";
+ private static final int BUFFER_SIZE = 64 * 1024;
+
+ @Override
+ public Set extract(Path file) throws IOException {
+ MessageDigest digest = newDigest();
+ try (InputStream raw = Files.newInputStream(file);
+ DigestInputStream in = new DigestInputStream(raw, digest)) {
+ byte[] buf = new byte[BUFFER_SIZE];
+ // Drain through the digest stream; we only care about side effects on the digest.
+ while (in.read(buf) != -1) {
+ // no-op
+ }
+ }
+ String hex = HexFormat.of().formatHex(digest.digest());
+ return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
+ }
+
+ @Override
+ public String name() {
+ return SIGNATURE_TYPE;
+ }
+
+ private static MessageDigest newDigest() {
+ try {
+ return MessageDigest.getInstance(ALGORITHM);
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated by every JDK; unreachable in practice.
+ throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
+ }
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java
new file mode 100644
index 000000000..2d261ee02
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java
@@ -0,0 +1,111 @@
+package stirling.software.saas.payg.lineage;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.payg.model.ArtifactKind;
+
+/**
+ * Default detector. Composes signatures from every registered {@link LineageSignatureExtractor}
+ * (currently just SHA-256 byte hash; future PDF-aware extractors are drop-in additions) and
+ * delegates lookup + persistence to a {@link JobLineageStore}.
+ */
+@Slf4j
+@Component
+@Profile("saas")
+public class DefaultHashLineageDetector implements HashLineageDetector {
+
+ private final List extractors;
+ private final JobLineageStore store;
+ private final Duration workflowWindow;
+
+ public DefaultHashLineageDetector(
+ List extractors,
+ JobLineageStore store,
+ @Value("${payg.lineage.workflow-window:PT5M}") Duration workflowWindow) {
+ Objects.requireNonNull(extractors, "extractors");
+ Objects.requireNonNull(store, "store");
+ Objects.requireNonNull(workflowWindow, "workflowWindow");
+ if (extractors.isEmpty()) {
+ throw new IllegalStateException(
+ "DefaultHashLineageDetector requires at least one LineageSignatureExtractor"
+ + " bean — none registered.");
+ }
+ if (workflowWindow.isNegative() || workflowWindow.isZero()) {
+ // A non-positive window would mean "since = now + |window|", so the > comparison
+ // only matches jobs in the future — i.e. nothing matches, silently. Fail loud
+ // instead.
+ throw new IllegalArgumentException(
+ "payg.lineage.workflow-window must be positive, got " + workflowWindow);
+ }
+ this.extractors = List.copyOf(extractors);
+ this.store = store;
+ this.workflowWindow = workflowWindow;
+ }
+
+ @Override
+ public Optional detect(Long userId, Path inputFile) throws IOException {
+ Objects.requireNonNull(userId, "userId");
+ Objects.requireNonNull(inputFile, "inputFile");
+
+ Set signatures = extractAll(inputFile);
+ if (signatures.isEmpty()) {
+ // No extractor produced anything for this content. Treat as no-match.
+ log.debug("No signatures extracted from {}; lineage check returns empty.", inputFile);
+ return Optional.empty();
+ }
+
+ return store.findOpenJobForSignatures(userId, signatures, workflowWindow);
+ }
+
+ @Override
+ public void record(UUID jobId, Path file, ArtifactKind kind) throws IOException {
+ Objects.requireNonNull(jobId, "jobId");
+ Objects.requireNonNull(file, "file");
+ Objects.requireNonNull(kind, "kind");
+
+ Set signatures = extractAll(file);
+ if (signatures.isEmpty()) {
+ log.debug(
+ "No signatures extracted from {} for job {} ({}); nothing recorded.",
+ file,
+ jobId,
+ kind);
+ return;
+ }
+ store.record(jobId, signatures, kind);
+ }
+
+ private Set extractAll(Path file) {
+ Set union = new HashSet<>();
+ for (LineageSignatureExtractor extractor : extractors) {
+ try {
+ union.addAll(extractor.extract(file));
+ } catch (IOException e) {
+ // A single extractor failing on file IO / format parse (e.g. PDF-aware extractor
+ // on a malformed PDF) must not block other extractors from contributing. Log and
+ // continue. RuntimeExceptions deliberately propagate — they signal bugs we want
+ // to surface, not "expected" extractor-doesn't-fit-this-content failures.
+ log.debug(
+ "Extractor '{}' failed on {} ({}); continuing with other extractors.",
+ extractor.name(),
+ file,
+ e.getMessage());
+ }
+ }
+ return union;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java
new file mode 100644
index 000000000..60aa589bf
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java
@@ -0,0 +1,36 @@
+package stirling.software.saas.payg.lineage;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Optional;
+import java.util.UUID;
+
+import stirling.software.saas.payg.model.ArtifactKind;
+
+/**
+ * High-level lineage API. Decides whether a tool call should join an existing open process by
+ * comparing the incoming file's signatures against signatures previously recorded for that user's
+ * open processes.
+ *
+ *
Two operations:
+ *
+ *
+ *
{@link #detect} — pre-execution. Caller asks "for this user about to run a tool on this
+ * file, is there an open process to join?"
+ *
{@link #record} — post-execution (for outputs) or post-charge (for inputs). Caller records
+ * the file's signatures against the job so subsequent tool calls can lineage-match on it.
+ *
+ *
+ *
Both methods take a {@link Path}; the caller is expected to have already materialised the
+ * upload / response body to a managed temp file (via {@code TempFileManager}). The detector
+ * delegates signature extraction to one or more {@link LineageSignatureExtractor}s and storage to a
+ * {@link JobLineageStore}, both of which are swappable.
+ */
+public interface HashLineageDetector {
+
+ /** Looks up an open process for this user whose recorded signatures match the input file. */
+ Optional detect(Long userId, Path inputFile) throws IOException;
+
+ /** Records the file's signatures against the given job as either INPUT or OUTPUT. */
+ void record(UUID jobId, Path file, ArtifactKind kind) throws IOException;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java
new file mode 100644
index 000000000..7ab46d9bd
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java
@@ -0,0 +1,39 @@
+package stirling.software.saas.payg.lineage;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import stirling.software.saas.payg.model.ArtifactKind;
+
+/**
+ * Persistence boundary for lineage data. Two operations: record signatures against a job, and look
+ * up the open job (if any) that previously recorded a matching signature for the same user.
+ *
+ *
This interface deliberately knows nothing about the storage technology — the production impl
+ * is JPA-backed against {@code job_artifact_hash}, and a future Redis-backed impl will be a
+ * straight swap. The detector and any callers only see this interface.
+ *
+ *
Signature values are stored using {@link LineageSignature#asStorageKey()}, so multiple
+ * signature types (SHA-256, PDF {@code /ID}, etc.) coexist on the same table without a separate
+ * column.
+ */
+public interface JobLineageStore {
+
+ /** Records every signature in {@code signatures} against the given job + artifact kind. */
+ void record(UUID jobId, Set signatures, ArtifactKind kind);
+
+ /**
+ * Finds the most-recently-active open job (owned by {@code userId}, whose {@code last_step_at}
+ * is within {@code workflowWindow}) whose recorded artifacts include any of the supplied
+ * candidate signatures. Returns the first match; ordering across multiple matches is by job
+ * activity recency.
+ */
+ Optional findOpenJobForSignatures(
+ Long userId, Set candidates, Duration workflowWindow);
+
+ /** Deletes records created before {@code cutoff}. Returns the number of rows removed. */
+ int pruneOlderThan(Instant cutoff);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java
new file mode 100644
index 000000000..4da4cd0b5
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java
@@ -0,0 +1,88 @@
+package stirling.software.saas.payg.lineage;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.domain.Limit;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+
+import stirling.software.saas.payg.job.JobArtifactHash;
+import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
+import stirling.software.saas.payg.model.ArtifactKind;
+import stirling.software.saas.payg.model.JobStatus;
+import stirling.software.saas.payg.repository.JobArtifactHashRepository;
+
+/**
+ * JPA-backed {@link JobLineageStore} against the {@code job_artifact_hash} table. The lookup runs
+ * as a single joined query against {@code processing_job} so status + window filtering happens at
+ * the database, not in-process.
+ *
+ *
Signatures are persisted using {@link LineageSignature#asStorageKey()} ({@code "type:value"})
+ * so multiple signature types coexist on the same column without a schema change.
+ */
+@Component
+@Profile("saas")
+@RequiredArgsConstructor
+public class JpaJobLineageStore implements JobLineageStore {
+
+ private final JobArtifactHashRepository hashRepository;
+
+ @Override
+ @Transactional
+ public void record(UUID jobId, Set signatures, ArtifactKind kind) {
+ Objects.requireNonNull(jobId, "jobId");
+ Objects.requireNonNull(signatures, "signatures");
+ Objects.requireNonNull(kind, "kind");
+ if (signatures.isEmpty()) {
+ return;
+ }
+ // saveAll + @Transactional → one transaction, all-or-nothing. Without this, a multi-
+ // signature record() could leave partial state if a save mid-way fails.
+ List rows = new ArrayList<>(signatures.size());
+ for (LineageSignature signature : signatures) {
+ JobArtifactHash row = new JobArtifactHash();
+ row.setId(new JobArtifactHashId(jobId, signature.asStorageKey(), kind));
+ rows.add(row);
+ }
+ hashRepository.saveAll(rows);
+ }
+
+ @Override
+ public Optional findOpenJobForSignatures(
+ Long userId, Set candidates, Duration workflowWindow) {
+ Objects.requireNonNull(userId, "userId");
+ Objects.requireNonNull(candidates, "candidates");
+ Objects.requireNonNull(workflowWindow, "workflowWindow");
+ if (candidates.isEmpty()) {
+ return Optional.empty();
+ }
+
+ List storageKeys = candidates.stream().map(LineageSignature::asStorageKey).toList();
+ LocalDateTime since = LocalDateTime.now().minus(workflowWindow);
+
+ List matches =
+ hashRepository.findOpenJobsForSignatures(
+ userId, JobStatus.OPEN, since, storageKeys, Limit.of(1));
+ return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0));
+ }
+
+ @Override
+ @Transactional
+ public int pruneOlderThan(Instant cutoff) {
+ Objects.requireNonNull(cutoff, "cutoff");
+ return hashRepository.deleteOlderThan(
+ LocalDateTime.ofInstant(cutoff, ZoneId.systemDefault()));
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageMatch.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageMatch.java
new file mode 100644
index 000000000..c22759614
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageMatch.java
@@ -0,0 +1,19 @@
+package stirling.software.saas.payg.lineage;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import stirling.software.saas.payg.model.ArtifactKind;
+
+/**
+ * Result of a successful lineage lookup: the open job a tool call should join, plus the {@link
+ * ArtifactKind} of the recorded artifact that produced the match (was the matched hash the prior
+ * call's input or its output?) and the job's {@code last_step_at} so the caller can decide whether
+ * to extend the workflow window.
+ *
+ *
If a single job has multiple recorded signatures that all match the candidate set (e.g. both
+ * its INPUT and OUTPUT hashes align with what the caller offered), {@code matchedKind} reflects one
+ * of those matches — ordering across same-job-different-kind rows is unspecified. Callers needing
+ * to enumerate every matched kind should query {@link JobLineageStore} directly.
+ */
+public record LineageMatch(UUID jobId, ArtifactKind matchedKind, LocalDateTime jobLastStepAt) {}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignature.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignature.java
new file mode 100644
index 000000000..3402b4485
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignature.java
@@ -0,0 +1,50 @@
+package stirling.software.saas.payg.lineage;
+
+import java.util.Objects;
+
+/**
+ * A single identity signal for a piece of content. The detector treats {@code (type, value)} as an
+ * opaque equality key — two signatures match if both components are equal. Format:
+ *
+ *
{@code value} is the strategy-specific identifier, encoded as a string short enough to fit
+ * in {@code job_artifact_hash.content_hash} (VARCHAR(128)).
+ *
+ *
+ *
Persisted as the concatenation {@code "{type}:{value}"} so multiple signature types can
+ * coexist on the same {@code job_artifact_hash} table without a separate column.
+ */
+public record LineageSignature(String type, String value) {
+
+ public LineageSignature {
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(value, "value");
+ if (type.isBlank()) {
+ throw new IllegalArgumentException("signature type must not be blank");
+ }
+ if (type.contains(":")) {
+ throw new IllegalArgumentException("signature type must not contain ':'");
+ }
+ if (value.isBlank()) {
+ throw new IllegalArgumentException("signature value must not be blank");
+ }
+ }
+
+ /** Storage form: {@code "type:value"}. */
+ public String asStorageKey() {
+ return type + ":" + value;
+ }
+
+ /** Parses a storage-form key back into a {@code LineageSignature}. */
+ public static LineageSignature fromStorageKey(String key) {
+ Objects.requireNonNull(key, "key");
+ int colon = key.indexOf(':');
+ if (colon <= 0 || colon == key.length() - 1) {
+ throw new IllegalArgumentException(
+ "Storage key must be of the form 'type:value': " + key);
+ }
+ return new LineageSignature(key.substring(0, colon), key.substring(colon + 1));
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignatureExtractor.java
new file mode 100644
index 000000000..7b5f94b65
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/LineageSignatureExtractor.java
@@ -0,0 +1,34 @@
+package stirling.software.saas.payg.lineage;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Set;
+
+/**
+ * Extracts one or more {@link LineageSignature}s from a file. Lineage is decided at the file level:
+ * the extractor sees the whole content and returns whatever identity signals it can pull out.
+ *
+ *
The default {@link ByteHashSignatureExtractor} returns a single SHA-256 of the bytes. A future
+ * format-aware extractor (e.g. for PDFs, opening with jpdfium to read {@code /ID[0]} or a
+ * content-stream hash) is just another {@link org.springframework.stereotype.Component} bean
+ * implementing this interface — no changes to the detector or the store. Multiple extractors can be
+ * composed; the detector unions their signatures and matches on any of them.
+ *
+ *
Callers materialise upload bodies / response bodies to a {@link Path} (via {@code
+ * TempFileManager}) before extraction so the extractor can stream-hash and, optionally,
+ * parse the file with a format-specific library — both from the same on-disk byte sequence.
+ */
+public interface LineageSignatureExtractor {
+
+ /**
+ * Returns every signature this extractor can derive from {@code file}. Empty set is legal —
+ * meaning "this extractor doesn't know how to fingerprint this content" — and is normal for
+ * format-specific extractors that decline to handle non-matching inputs.
+ */
+ Set extract(Path file) throws IOException;
+
+ /**
+ * Short, stable name for logging and diagnostics ({@code "sha256"}, {@code "pdf-id"}, etc.).
+ */
+ String name();
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java
index c9cb2af9c..0c3af74db 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java
@@ -1,8 +1,10 @@
package stirling.software.saas.payg.repository;
import java.time.LocalDateTime;
+import java.util.Collection;
import java.util.List;
+import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -11,6 +13,7 @@ import org.springframework.stereotype.Repository;
import stirling.software.saas.payg.job.JobArtifactHash;
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
+import stirling.software.saas.payg.lineage.LineageMatch;
import stirling.software.saas.payg.model.JobStatus;
@Repository
@@ -18,21 +21,27 @@ public interface JobArtifactHashRepository
extends JpaRepository {
/**
- * Lineage lookup: find the open job (if any) whose recorded input/output hashes include the
- * supplied content hash, scoped to one user and the workflow window.
+ * Lineage lookup: find open jobs (owned by {@code userId}, {@code last_step_at > since}) whose
+ * recorded artifacts include any of the supplied storage-form signature keys. Ordered by job
+ * activity recency. Caller passes {@link Limit#of(int)} to bound the result set — for the
+ * single-match hot path use {@code Limit.of(1)} so the DB doesn't materialise unwanted rows.
*/
@Query(
- "SELECT j.ownerUserId, h.id.jobId FROM JobArtifactHash h"
+ "SELECT new stirling.software.saas.payg.lineage.LineageMatch("
+ + " h.id.jobId, h.id.kind, j.lastStepAt)"
+ + " FROM JobArtifactHash h"
+ " JOIN ProcessingJob j ON j.id = h.id.jobId"
+ " WHERE j.ownerUserId = :userId"
+ " AND j.status = :openStatus"
+ " AND j.lastStepAt > :since"
- + " AND h.id.contentHash = :contentHash")
- List