Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-dragons-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hephaestus": patch
---

Prevents observations from crossing workspace boundaries and preserves the practice revision that produced each observation.
6 changes: 4 additions & 2 deletions docs/contributor/erd/schema.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -780,9 +780,10 @@ erDiagram
TEXT evidence_rationale
TIMESTAMPTZ observed_at "NOT NULL"
VARCHAR(64) recurrence_key
BIGINT practice_revision_id FK
BIGINT practice_revision_id FK "NOT NULL"
VARCHAR(8) assessment
VARCHAR(16) origin "NOT NULL"
BIGINT workspace_id FK "NOT NULL"
}

Organization {
Expand Down Expand Up @@ -865,7 +866,7 @@ erDiagram
}

Practice {
BIGINT id PK
BIGINT id PK,UK
BIGINT workspace_id FK,UK "NOT NULL"
VARCHAR(64) slug UK "NOT NULL"
VARCHAR(128) name "NOT NULL"
Expand Down Expand Up @@ -1572,6 +1573,7 @@ erDiagram
User ||--o{ Milestone : created_by
Practice ||--o{ Observation : has
PracticeRevision ||--o{ Observation : has
Workspace ||--o{ Observation : has
AgentJob ||--o{ Observation : has
User ||--o{ Observation : has
IdentityProvider ||--o{ Organization : references
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ private void record(
if (!summaryDelivered && !inlineDelivered) {
return;
}
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return;
}
Expand Down Expand Up @@ -437,7 +440,10 @@ private void recordSuppressedUnitInCurrentTransaction(
if (feedbackRepository.existsByAgentJobIdAndPosition(job.getId(), GATE_SUPPRESSED_UNIT_ORDINAL)) {
return; // already recorded (job retry)
}
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return;
}
Expand All @@ -457,7 +463,10 @@ public void recordSuppressedRemainder(
if (feedbackRepository.existsByAgentJobIdAndPosition(job.getId(), GATE_SUPPRESSED_UNIT_ORDINAL)) {
return;
}
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return;
}
Expand Down Expand Up @@ -526,7 +535,10 @@ public void recordRecoveredSummary(AgentJob job, String externalRef, String body
if (feedbackRepository.existsByAgentJobIdAndPosition(job.getId(), IN_CONTEXT_UNIT_ORDINAL)) {
return;
}
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return;
}
Expand Down Expand Up @@ -575,7 +587,10 @@ public void recordRecoveredSummary(AgentJob job, String externalRef, String body

@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public Optional<String> priorLiveIssueSummaryRef(AgentJob job) {
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return Optional.empty();
}
Expand Down Expand Up @@ -625,7 +640,7 @@ public void recordProposal(AgentJob job, @Nullable DeliveryContent delivery, Lis
if (body.isBlank()) return;
if (feedbackRepository.existsByAgentJobIdAndPosition(job.getId(), position)) return;
Map<String, Observation> stored = observationRepository
.findByAgentJobId(job.getId())
.findByAgentJobId(job.getId(), job.getWorkspace().getId())
.stream()
.filter(observation -> observation.getOccurrenceKey() != null)
.collect(
Expand Down Expand Up @@ -755,7 +770,10 @@ public void recordUndelivered(AgentJob job, @Nullable DeliveryContent delivery)
if (feedbackRepository.existsByAgentJobIdAndPosition(job.getId(), UNDELIVERED_UNIT_ORDINAL)) {
return; // already recorded (job retry)
}
List<Observation> observations = observationRepository.findByAgentJobId(job.getId());
List<Observation> observations = observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
);
if (observations.isEmpty()) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ public List<ValidatedObservation> awaitingApproval(AgentJob job, List<ValidatedO

private void recordWithheld(AgentJob job, List<ValidatedObservation> withheld, FeedbackSuppressionReason reason) {
Map<String, Observation> byOccurrence = new HashMap<>();
for (Observation observation : observationRepository.findByAgentJobId(job.getId())) {
for (Observation observation : observationRepository.findByAgentJobId(
job.getId(),
job.getWorkspace().getId()
)) {
byOccurrence.put(observation.getOccurrenceKey(), observation);
}
int index = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private String buildPrompt(int issueNumber, String repoName, AgentJob job) {
public void deliver(AgentJob job) {
ObservationAdmissionService.requireMatchingCompositionDigest(job);
List<PracticeDetectionResultParser.ValidatedObservation> observations = observationRepository
.findByAgentJobId(job.getId())
.findByAgentJobId(job.getId(), job.getWorkspace().getId())
.stream()
.map(this::validated)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public ObjectNode admit(UUID jobId, JsonNode submitted) {
String existing = currentMetadata == null ? "" : currentMetadata.path(DIGEST_METADATA_KEY).asString();
if (!existing.isBlank()) {
if (!existing.equals(digest)) throw new AdmissionConflictException();
return response(job, existing, observations.findByAgentJobId(jobId));
return response(job, existing, observations.findByAgentJobId(jobId, job.getWorkspace().getId()));
}
switch (job.getJobType()) {
case PULL_REQUEST_REVIEW -> pullRequests.admitObservations(job, submitted);
Expand All @@ -74,7 +74,7 @@ public ObjectNode admit(UUID jobId, JsonNode submitted) {
metadata.put(DIGEST_METADATA_KEY, digest);
job.setMetadata(metadata);
jobs.save(job);
return response(job, digest, observations.findByAgentJobId(jobId));
return response(job, digest, observations.findByAgentJobId(jobId, job.getWorkspace().getId()));
}

private byte[] serializedPayload(JsonNode submitted) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
UUID.randomUUID(),
occurrenceKey,
job.getId(),
job.getWorkspace().getId(),
practice.getId(),
practiceRevisionId,
artifactKind.value(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ public void deliver(AgentJob job) {

private void deliverAdmitted(AgentJob job) {
List<PracticeDetectionResultParser.ValidatedObservation> scopedObservations = observationRepository
.findByAgentJobId(job.getId())
.findByAgentJobId(job.getId(), job.getWorkspace().getId())
.stream()
.map(this::validated)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public ReactionDecision evaluate(AgentJob job, List<ValidatedObservation> scoped
if (!reviewProperties.reactionSuppression()) {
return new ReactionDecision(scopedObservations, 0);
}
List<Observation> persisted = observationRepository.findByAgentJobId(job.getId());
List<Observation> persisted = observationRepository.findByAgentJobId(job.getId(), job.getWorkspace().getId());
if (persisted.isEmpty()) {
return new ReactionDecision(scopedObservations, 0);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ private int route(UUID agentJobId, UUID outputJobId, Long workspaceId) {
if (sourceJob == null || !deliveryPolicy.allowsComposition(sourceJob, DeliveryPolicySurface.CONVERSATION)) {
return 0;
}
List<Observation> observations = observationRepository.findByAgentJobId(agentJobId);
List<Observation> observations = observationRepository.findByAgentJobId(agentJobId, workspaceId);
if (observations.isEmpty()) {
return 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public int prepare(
// observations arrive from whichever caller routed them and may be detached, so walking
// o.practice.slug would make a row supersedable — or a composed message deliverable — depending on
// whether that caller happened to hold a session.
Map<UUID, String> practiceSlugs = practiceSlugsOf(ordered);
Map<UUID, String> practiceSlugs = practiceSlugsOf(ordered, workspaceId);
Map<Long, Integer> perRecipientCount = new HashMap<>();
// Newly CREATED units only (re-run no-ops excluded) — feeds the per-recipient prepared event.
Map<Long, Integer> newlyPreparedByRecipient = new HashMap<>();
Expand Down Expand Up @@ -340,13 +340,16 @@ private static boolean supersedes(@Nullable ComposedFeedbackUnit move, String ow
* and, because a habit thread is scoped to the practice, the same spelling is what the continuity key is
* derived from, on this lane and on the in-app lane alike.
*/
private Map<UUID, String> practiceSlugsOf(List<Observation> observations) {
private Map<UUID, String> practiceSlugsOf(List<Observation> observations, Long workspaceId) {
List<UUID> ids = observations.stream().map(Observation::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) {
return Map.of();
}
Map<UUID, String> slugs = new HashMap<>(ids.size());
for (ObservationRepository.ObservationPracticeSlug row : observationRepository.practiceSlugsFor(ids)) {
for (ObservationRepository.ObservationPracticeSlug row : observationRepository.practiceSlugsFor(
ids,
workspaceId
)) {
String slug = row.getPracticeSlug();
if (slug != null && !slug.isBlank()) {
slugs.put(row.getObservationId(), normalizeSlug(slug));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public List<Observation> admit(List<Observation> observations, long workspaceId,
WorkspaceReviewDefaults defaults = workspaceDefaults.forWorkspace(workspaceId);
Map<UUID, PracticeAutonomy> autonomyByPracticeId = autonomyByPracticeId(
observations,
workspaceId,
defaults.defaultAutonomy()
);
List<Observation> admitted = new ArrayList<>();
Expand Down Expand Up @@ -105,14 +106,15 @@ public ConversationRoutingDecision route(

private Map<UUID, PracticeAutonomy> autonomyByPracticeId(
List<Observation> observations,
Long workspaceId,
PracticeAutonomy workspaceDefault
) {
List<UUID> ids = observations.stream().map(Observation::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) {
return Map.of();
}
Map<UUID, PracticeAutonomy> autonomyByPracticeId = new HashMap<>();
for (var row : observationRepository.findPracticeAutonomyFor(ids)) {
for (var row : observationRepository.findPracticeAutonomyFor(ids, workspaceId)) {
autonomyByPracticeId.put(
row.getObservationId(),
AutonomyResolver.resolvePractice(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ private int route(UUID agentJobId, UUID outputJobId, Long workspaceId) {
// One recipient in practice — a review job files its observations against one person — but
// read rather than assumed, so a kind that ever files against several does not silently
// deliver all of their patterns to whoever happened to be first.
List<Long> recipients = observationRepository.findSubjectUserIdsByAgentJobId(agentJobId);
List<Long> recipients = observationRepository.findSubjectUserIdsByAgentJobId(agentJobId, workspaceId);
// Every recipient's units share this job's id, and (agent_job_id, position) is unique, so each
// recipient gets its own slice of the band. The query orders by user id, so a re-run assigns
// the same slices and the idempotency guard still recognises what it already wrote.
Expand Down Expand Up @@ -212,7 +212,7 @@ private int prepareFor(
InAppRoutingDecision decision = InAppFeedbackRouter.route(
message,
evidence,
effectiveTier(evidence, workspaceDefault),
effectiveTier(evidence, workspaceId, workspaceDefault),
subjectRole(evidence),
feedbackRepository
.lastInAppSurfacedAt(workspaceId, recipientUserId, message.practiceSlug())
Expand Down Expand Up @@ -276,13 +276,17 @@ private List<Observation> visibleEvidence(
* projection rather than by walking associations — the same reason {@code FeedbackChannelRouter}
* projects it: the routing rule must not depend on whether the caller holds a session.
*/
private @Nullable PracticeAutonomy effectiveTier(List<Observation> evidence, PracticeAutonomy workspaceDefault) {
private @Nullable PracticeAutonomy effectiveTier(
List<Observation> evidence,
Long workspaceId,
PracticeAutonomy workspaceDefault
) {
List<UUID> ids = evidence.stream().map(Observation::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) {
return null;
}
return observationRepository
.findPracticeAutonomyFor(ids)
.findPracticeAutonomyFor(ids, workspaceId)
.stream()
.findFirst()
.map(row ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
uniqueConstraints = { @UniqueConstraint(name = "uk_observation_occurrence", columnNames = { "occurrence_key" }) },
indexes = {
@Index(name = "idx_observation_practice_observed", columnList = "practice_id, observed_at DESC"),
@Index(name = "idx_observation_workspace", columnList = "workspace_id"),
@Index(name = "idx_observation_agent_job", columnList = "agent_job_id"),
@Index(name = "idx_observation_target", columnList = "artifact_kind, artifact_id"),
@Index(
Expand Down Expand Up @@ -75,25 +76,25 @@ public class Observation {
@Column(name = "agent_job_id", nullable = false, columnDefinition = "UUID")
private UUID agentJobId;

/**
* The practice measured. Deliberately not cascade-deleted: an observation is immutable and the
* substrate for longitudinal research, so pruning a practice must not erase everyone's history
* against it — retire it instead.
*/
@NotNull
@Column(name = "workspace_id", nullable = false)
private Long workspaceId;

/** The practice measured. Practices with observations must be retired rather than deleted. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "practice_id", nullable = false, foreignKey = @ForeignKey(name = "fk_observation_practice"))
private Practice practice;

/**
* The {@link PracticeRevision} (SCD-2 snapshot) the detector evaluated this observation against,
* pinning it to criteria as they were for reproducibility. NULL means the observation predates
* criteria versioning, not a missing reference — {@code ON DELETE SET NULL} lets the immutable
* observation outlive a pruned revision.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "practice_revision_id", foreignKey = @ForeignKey(name = "fk_observation_revision"))
@OnDelete(action = OnDeleteAction.SET_NULL)
/** The {@link PracticeRevision} evaluated to produce this observation. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "practice_revision_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_observation_revision")
)
@OnDelete(action = OnDeleteAction.RESTRICT)
private PracticeRevision practiceRevision;

@NotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@
@Entity
@Table(
name = "practice",
uniqueConstraints = @UniqueConstraint(
name = "uk_practice_workspace_slug",
columnNames = { "workspace_id", "slug" }
),
uniqueConstraints = {
@UniqueConstraint(name = "uk_practice_workspace_slug", columnNames = { "workspace_id", "slug" }),
@UniqueConstraint(name = "uk_practice_workspace_id", columnNames = { "id", "workspace_id" }),
},
indexes = {
@Index(name = "idx_practice_workspace_autonomy", columnList = "workspace_id, autonomy"),
@Index(name = "idx_practice_practice_area", columnList = "practice_area_id"),
Expand Down
Loading
Loading