merge: integrate feat/backlog-burndown into master

master had advanced 16 commits past feat/backlog-burndown's base, and the two
branches independently built four of the same features. Resolved 26 conflicts.

Overlap features — kept master's implementation (more complete / production-wired /
more robust), dropped the feature branch's parallel constellation:
- llama-server health probe: kept master's event-store-backed tps probe; dropped the
  branch's LlamaLivenessClient (liveness-only, throughput unwired).
- event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe.
- brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording);
  dropped the branch's exact-set-diff BriefEchoComparator/Extractor.
- static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner,
  wired); dropped the branch's structured-finding static_check stage (no-op seam).
  Its structured-findings model is filed as a follow-up in BACKLOG.

Feature-branch net-new work brought in and kept (master had none):
- native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer,
  dependency graph + gates, decompose, REST/CLI, TUI task board)
- critique-outcome producer (role-rel §6 — master had deferred it)
- stage-level plan checkpointing (C-A2, folded into runPostStageGates)
- CLAUDE.md/AGENTS.md L0 standing context
- cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser)

Verified: full Gradle compile (all modules + tests) green; tests pass for core:events,
core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go
go build + go test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
@@ -0,0 +1,31 @@
package com.correx.core.approvals
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import java.nio.file.Paths
/**
* Reserved event-store stream that holds cross-session approval grants (PROJECT and GLOBAL scopes).
*
* Session-scoped grants live in their own session's stream and die with it; project/global grants
* must outlive any single session and be visible to every other one, so they are appended here
* instead. The approval gate folds this ledger ([com.correx.core.approvals.DefaultApprovalReducer]
* over [DefaultApprovalRepository.getApprovalState]) and unions its grants with the running
* session's own before evaluating — see `SessionOrchestrator`. Revocation appends an
* [com.correx.core.events.events.ApprovalGrantExpiredEvent] to the same stream, which the reducer
* drops, so the audit trail stays intact (invariant #9: record facts, replay reads them).
*
* The id is a sentinel, not a real session; it never carries a workflow run.
*/
val GRANT_LEDGER_SESSION_ID = SessionId("__grant_ledger__")
/**
* Derives a stable [ProjectId] from a workspace root path so PROJECT-scoped grants created in one
* session match later sessions opened on the same repository. Both the grant-creation site (server)
* and the approval gate (orchestrator) run in the same process, so canonicalising to a normalised
* absolute path yields the same id on both sides regardless of how the root was spelled.
*/
object ProjectIdentity {
fun of(workspaceRoot: String): ProjectId =
ProjectId(Paths.get(workspaceRoot).toAbsolutePath().normalize().toString())
}
@@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable
sealed interface GrantScope {
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
// A null toolName is rejected at grant-creation time; it is kept nullable here only
// for backward-compatible deserialization of legacy events.
// for backward-compatible deserialization of legacy events. Every operator-creatable
// scope is tool-bound so a grant can never become a blanket "approve everything"
// (that is YOLO mode, configured separately).
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
@Serializable data class STAGE(val stageId: StageId) : GrantScope
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
/** Auto-approve [toolName] for any session whose workspace resolves to [projectId]. */
@Serializable data class PROJECT(val projectId: ProjectId, val toolName: String? = null) : GrantScope
/** Auto-approve [toolName] for every session on this machine (the widest scope). */
@Serializable data class GLOBAL(val toolName: String? = null) : GrantScope
}
@@ -0,0 +1,33 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One prior decision/ADR surfaced as semantically near a new architect decision.
* [source] is the L3 entry's turnId (or a decision id) the line came from; [score] is the
* similarity of the prior decision to the new one.
*/
@Serializable
data class RelatedDecision(
val summary: String,
val score: Double,
val source: String,
)
/**
* Display-only architect contradiction surfacing (BACKLOG §B-§4). Lists prior decisions
* semantically near the new one so the operator can spot a reversal. Non-blocking: this event
* only surfaces similarity candidates for an operator to eyeball — it never halts or fails a
* stage, and there is no LLM judge.
*/
@Serializable
@SerialName("PossibleContradictionFlagged")
data class PossibleContradictionFlaggedEvent(
val sessionId: SessionId,
val stageId: StageId,
val decisionSummary: String,
val related: List<RelatedDecision>,
) : EventPayload
@@ -0,0 +1,62 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A critic's verdict for one review iteration. */
@Serializable
enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED }
/**
* The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its
* [verdict] for that iteration. This is the **producing side** of critique calibration: the
* reviewer-loop runtime correlates the sequence of these across iterations into
* [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the
* `:core:critique` projection then folds into per-(model, role) precision.
*
* [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the
* correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash]
* identifies the model that produced the findings (carried through to the outcome event so
* calibration is per-model).
*/
@Serializable
@SerialName("CritiqueFindingsRecorded")
data class CritiqueFindingsRecordedEvent(
val sessionId: SessionId,
val stageId: StageId,
val role: CritiqueRole,
val modelHash: String,
val iteration: Int,
val verdict: CritiqueVerdict,
val findings: List<CritiqueFinding>,
) : EventPayload
/**
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
* INCONCLUSIVE = no signal either way.
*/
@Serializable
enum class CritiqueOutcome { UPHELD, DISMISSED, INCONCLUSIVE }
/**
* Correlates one prior [CritiqueFinding] (by [findingId]) with how it actually turned out, so a
* critic's precision can be tracked per model and per role over time (the calibration projection
* in `:core:critique`). [modelHash] identifies the model that produced the finding; [severity]
* is carried so calibration can be sliced by severity band without re-reading the finding.
*
* This is the recording half only — who decides UPHELD/DISMISSED lives in the reviewer-loop
* runtime and is wired separately.
*/
@Serializable
@SerialName("CritiqueOutcomeCorrelated")
data class CritiqueOutcomeCorrelatedEvent(
val sessionId: SessionId,
val findingId: String,
val role: CritiqueRole,
val modelHash: String,
val severity: CritiqueSeverity,
val outcome: CritiqueOutcome,
) : EventPayload
@@ -0,0 +1,34 @@
package com.correx.core.events.events
import kotlinx.serialization.Serializable
/** Which critic role produced a [CritiqueFinding]. */
@Serializable
enum class CritiqueRole { PLAN_CRITIC, CODE_REVIEWER }
/** Severity of a [CritiqueFinding], highest-impact first. */
@Serializable
enum class CritiqueSeverity { BLOCKER, MAJOR, MINOR, NIT }
/**
* One structured finding emitted by a critic (plan critic) or reviewer (code reviewer),
* replacing the free-text verdict prose those roles produced before. Shared by both roles so
* findings can be counted, ranked, and calibrated uniformly.
*
* [id] is a stable identifier for the finding, used to correlate it with its eventual outcome
* (see [CritiqueOutcomeCorrelatedEvent]). [category] is a free-form classification
* (e.g. "correctness", "missing_requirement", "style"). [location] optionally points at a
* `file:line` or an artifact/stage reference the finding concerns.
*
* Distinct from `core.validation.ValidationIssue`, which models *structural* validation
* (graph/schema) rather than a critic's judgement.
*/
@Serializable
data class CritiqueFinding(
val id: String,
val role: CritiqueRole,
val severity: CritiqueSeverity,
val category: String,
val message: String,
val location: String? = null,
)
@@ -0,0 +1,21 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Grants additional egress [hosts] for this session (e.g. the hosts drawn from an approved
* research source list). Additive to the static `networkAllowedHosts` allow-list: a host is
* permitted if it matches the static list OR any host granted to the session, so egress can be
* widened per-session without loosening the global default. Host matching honours the same
* exact-or-suffix semantics the static allow-list uses (a granted "example.com" covers
* "api.example.com"). [reason] is free-form operator context (e.g. which source list was approved).
*/
@Serializable
@SerialName("EgressHostsGranted")
data class EgressHostsGrantedEvent(
val sessionId: SessionId,
val hosts: Set<String>,
val reason: String = "",
) : EventPayload
@@ -31,3 +31,19 @@ data class IdeaDiscardedEvent(
val ideaId: String,
val sessionId: SessionId,
) : EventPayload
/**
* The operator promoted an idea from the cross-session board into the curated per-repo profile
* (`<workspaceRoot>/.correx/project.toml`, as a `conventions` entry). Like [IdeaDiscardedEvent] this
* is a tombstone — the original [IdeaCapturedEvent] stays in the log (and replays), but the idea-board
* projection filters out any promoted [ideaId]. [sessionId] is the capturing session (so all of one
* idea's events stay co-located); [text] records the convention text written to the profile.
*/
@Serializable
@SerialName("IdeaPromoted")
data class IdeaPromotedEvent(
val ideaId: String,
val sessionId: SessionId,
val text: String,
val timestampMs: Long,
) : EventPayload
@@ -0,0 +1,41 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A research T2 fetch completed and produced content at [contentSha256] (research-workflow-spec §3/§5).
*
* Promotes the fetch-quality + content-hash that previously lived only inside
* [ToolExecutionCompletedEvent] metadata into a first-class event, so "what sources were fetched"
* is queryable and renderable directly from the journal. Additive: the metadata write is retained
* for existing consumers. [quality] mirrors the extractor's quality marker as recorded in metadata.
*/
@Serializable
@SerialName("SourceFetched")
data class SourceFetchedEvent(
val sessionId: SessionId,
val stageId: StageId,
val url: String,
val contentSha256: String,
val quality: String,
val byteCount: Int = 0,
) : EventPayload
/**
* An extraction was retained but flagged low-quality — below the quality bar (research-workflow-spec §5)
* — for operator visibility. Emitted alongside [SourceFetchedEvent] when the fetch cleared rejection
* but fell under the minimum-content threshold (JS-rendered SPA, paywall); the content is still kept,
* the operator is simply warned that synthesis may want to route around it.
*/
@Serializable
@SerialName("LowQualityExtraction")
data class LowQualityExtractionEvent(
val sessionId: SessionId,
val stageId: StageId,
val url: String,
val contentSha256: String,
val reason: String,
) : EventPayload
@@ -1,6 +1,7 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -10,6 +11,20 @@ data class ChatSessionStartedEvent(
val sessionId: SessionId,
) : EventPayload
/**
* Records, in the session's own stream, that this run claimed [taskId] and is working it with the
* declared [affectedPaths] write scope — a session-local fact so the gates know the active task
* without scanning task streams. Re-emitted when the claimant edits affected_paths, so the folded
* scope stays current; SessionContextProjection takes the most recent.
*/
@Serializable
@SerialName("SessionWorkingTask")
data class SessionWorkingTaskEvent(
val sessionId: SessionId,
val taskId: TaskId,
val affectedPaths: List<String> = emptyList(),
) : EventPayload
@Serializable
@SerialName("SessionWorkspaceBound")
data class SessionWorkspaceBoundEvent(
@@ -37,3 +52,12 @@ data class ProjectProfileBoundEvent(
val conventions: List<String>,
val commands: Map<String, String>,
) : EventPayload
@Serializable
@SerialName("AgentInstructionsBound")
data class AgentInstructionsBoundEvent(
val sessionId: SessionId,
val workspaceRoot: String,
val sources: List<String>, // file names found, e.g. ["CLAUDE.md","AGENTS.md"]
val content: String, // concatenated, header-labeled instructions
) : EventPayload
@@ -0,0 +1,42 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *passed* variant means every expected slot was produced: [producedArtifacts] is a superset of
* [expectedArtifacts] (extra artifacts beyond the expected set do not fail the checkpoint).
*/
@Serializable
@SerialName("StageCheckpointPassed")
data class StageCheckpointPassedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
) : EventPayload
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *failed* variant accompanies the stage halt owned by `verifyProduces` and surfaces *why* the
* plan diverged: [missingArtifacts] are the expected slots ([expectedArtifacts] minus
* [producedArtifacts]) the stage never produced.
*/
@Serializable
@SerialName("StageCheckpointFailed")
data class StageCheckpointFailedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
val missingArtifacts: Set<String>,
) : EventPayload
@@ -0,0 +1,142 @@
package com.correx.core.events.events
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
* from these facts (mirroring how session status is derived from stage events).
*/
sealed interface TaskEvent {
val taskId: TaskId
}
@Serializable
@SerialName("TaskCreated")
data class TaskCreatedEvent(
override val taskId: TaskId,
val projectId: ProjectId,
val key: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskClaimed")
data class TaskClaimedEvent(
override val taskId: TaskId,
val claimant: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReleased")
data class TaskReleasedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskBlocked")
data class TaskBlockedEvent(
override val taskId: TaskId,
val reason: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnblocked")
data class TaskUnblockedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskSubmittedForReview")
data class TaskSubmittedForReviewEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCompleted")
data class TaskCompletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReopened")
data class TaskReopenedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCancelled")
data class TaskCancelledEvent(
override val taskId: TaskId,
val reason: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskEdited")
data class TaskEditedEvent(
override val taskId: TaskId,
val title: String? = null,
val goal: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAcceptanceCriteriaSet")
data class TaskAcceptanceCriteriaSetEvent(
override val taskId: TaskId,
val criteria: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAffectedPathsSet")
data class TaskAffectedPathsSetEvent(
override val taskId: TaskId,
val paths: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskLinked")
data class TaskLinkedEvent(
override val taskId: TaskId,
val targetId: String,
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
@SerialName("linkType") val type: TaskLinkType,
val targetKind: TaskTargetKind,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnlinked")
data class TaskUnlinkedEvent(
override val taskId: TaskId,
val targetId: String,
@SerialName("linkType") val type: TaskLinkType,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskNoteAdded")
data class TaskNoteAddedEvent(
override val taskId: TaskId,
val author: TaskNoteAuthor,
val body: String,
) : EventPayload, TaskEvent
/**
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
* projection drops the task from active views. Distinct from cancellation, which is a
* lifecycle outcome that stays visible as CANCELLED.
*/
@Serializable
@SerialName("TaskDeleted")
data class TaskDeletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolCapability
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent(
val toolName: String,
val tier: Tier,
val request: ToolRequest,
// The tool's declared capabilities, recorded at emission so replay classifies the call by what
// it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry.
// Defaulted for backward compatibility: events written before this field deserialize as empty.
val capabilities: Set<ToolCapability> = emptySet(),
) : EventPayload
@Serializable
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
@@ -13,14 +14,19 @@ import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.PlanLintCompletedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.SessionWorkingTaskEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
@@ -28,9 +34,11 @@ import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.JournalCompactedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.InferenceFailedEvent
@@ -46,6 +54,9 @@ import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
@@ -63,6 +74,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskDeletedEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@@ -116,11 +143,13 @@ val eventModule = SerializersModule {
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(SessionWorkingTaskEvent::class)
subclass(ChatTurnEvent::class)
subclass(RouterNarrationEvent::class)
subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class)
subclass(ProjectProfileBoundEvent::class)
subclass(AgentInstructionsBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
@@ -133,6 +162,31 @@ val eventModule = SerializersModule {
subclass(WorkflowProposedEvent::class)
subclass(IdeaCapturedEvent::class)
subclass(IdeaDiscardedEvent::class)
subclass(IdeaPromotedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(CritiqueFindingsRecordedEvent::class)
subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class)
subclass(PossibleContradictionFlaggedEvent::class)
subclass(SourceFetchedEvent::class)
subclass(LowQualityExtractionEvent::class)
subclass(EgressHostsGrantedEvent::class)
subclass(TaskCreatedEvent::class)
subclass(TaskClaimedEvent::class)
subclass(TaskReleasedEvent::class)
subclass(TaskBlockedEvent::class)
subclass(TaskUnblockedEvent::class)
subclass(TaskSubmittedForReviewEvent::class)
subclass(TaskCompletedEvent::class)
subclass(TaskReopenedEvent::class)
subclass(TaskCancelledEvent::class)
subclass(TaskEditedEvent::class)
subclass(TaskAcceptanceCriteriaSetEvent::class)
subclass(TaskAffectedPathsSetEvent::class)
subclass(TaskLinkedEvent::class)
subclass(TaskUnlinkedEvent::class)
subclass(TaskNoteAddedEvent::class)
subclass(TaskDeletedEvent::class)
}
}
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
typealias ArtifactId = TypeId
// Tasks types
typealias TaskId = TypeId
// Context types
typealias ContextPackId = TypeId
typealias ContextEntryId = TypeId
@@ -0,0 +1,18 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
*/
@Serializable
enum class TaskLinkType {
@SerialName("DEPENDS_ON") DEPENDS_ON,
@SerialName("BLOCKS") BLOCKS,
@SerialName("IMPLEMENTS") IMPLEMENTS,
@SerialName("RELATES_TO") RELATES_TO,
@SerialName("PRODUCED") PRODUCED,
@SerialName("CONTEXT") CONTEXT,
}
@@ -0,0 +1,11 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Who authored a task note — used to keep the agent and human lanes separable. */
@Serializable
enum class TaskNoteAuthor {
@SerialName("AGENT") AGENT,
@SerialName("HUMAN") HUMAN,
}
@@ -0,0 +1,16 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What a task link points at — recorded on the link so the context bundle dispatches resolution
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
*/
@Serializable
enum class TaskTargetKind {
@SerialName("TASK") TASK,
@SerialName("DOC") DOC,
@SerialName("ARTIFACT") ARTIFACT,
@SerialName("SESSION") SESSION,
}
@@ -0,0 +1,29 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.SessionId
/**
* Folds the additional egress hosts granted to one session. State is the running union of every
* [EgressHostsGrantedEvent.hosts] emitted for [sessionId]; grants are additive (set union) and
* unrelated events — including grants for other sessions — are ignored. The resulting
* `Set<String>` is the per-session input to
* [com.correx.core.toolintent.rules.EgressAllowlist.isAllowed], unioned with the static
* `networkAllowedHosts` at the network gate.
*/
class EgressAllowlistProjection(
private val sessionId: SessionId,
) : Projection<Set<String>> {
override fun initial(): Set<String> = emptySet()
override fun apply(state: Set<String>, event: StoredEvent): Set<String> {
val payload = event.payload
return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) {
state + payload.hosts
} else {
state
}
}
}
@@ -0,0 +1,21 @@
package com.correx.core.tools.contract
import kotlinx.serialization.Serializable
/**
* What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch
* on these — never on tool names — and they are recorded on
* [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a
* call by the capability it actually had rather than re-deriving it from the live registry.
*
* Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything
* above import it from here — the package name is unchanged, so the move is import-transparent.
*/
@Serializable
enum class ToolCapability {
FILE_READ,
FILE_WRITE,
NETWORK_ACCESS,
SHELL_EXEC,
PROCESS_SPAWN,
}
@@ -6,6 +6,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
@@ -81,6 +82,12 @@ class EventSerializationHardeningTest {
terminalStageId = stageId,
totalStages = 1,
),
"IdeaPromoted" to IdeaPromotedEvent(
ideaId = "idea-1",
sessionId = sessionId,
text = "cache the repo map across sessions",
timestampMs = 1_700_000_000_000,
),
)
@Test
@@ -0,0 +1,36 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.RelatedDecision
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ContradictionEventSerializationTest {
@Test
fun `PossibleContradictionFlaggedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = PossibleContradictionFlaggedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
decisionSummary = "Use Postgres for the event store.",
related = listOf(
RelatedDecision(
summary = "Decided to use SQLite for the event store.",
score = 0.91,
source = "project:/repo",
),
),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(
encoded.contains("\"type\":\"PossibleContradictionFlagged\""),
"SerialName must be present: $encoded",
)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,33 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.CritiqueOutcome
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.CritiqueRole
import com.correx.core.events.events.CritiqueSeverity
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CritiqueCalibrationEventSerializationTest {
@Test
fun `CritiqueOutcomeCorrelatedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = CritiqueOutcomeCorrelatedEvent(
sessionId = SessionId("s"),
findingId = "f-1",
role = CritiqueRole.CODE_REVIEWER,
modelHash = "sha256:abc",
severity = CritiqueSeverity.MAJOR,
outcome = CritiqueOutcome.UPHELD,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(
encoded.contains("\"type\":\"CritiqueOutcomeCorrelated\""),
"SerialName must be present: $encoded",
)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,24 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EgressEventSerializationTest {
@Test
fun `EgressHostsGrantedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = EgressHostsGrantedEvent(
sessionId = SessionId("s"),
hosts = setOf("example.com", "docs.rust-lang.org"),
reason = "approved research source list",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"EgressHostsGranted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.types.SessionId
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
assertEquals(sample, decoded)
}
@Test
fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = AgentInstructionsBoundEvent(
sessionId = SessionId("s1"),
workspaceRoot = "/repo",
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `round-trips with empty fields`() {
val sample: EventPayload = ProjectProfileBoundEvent(
@@ -0,0 +1,47 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ResearchSourceEventSerializationTest {
private val sessionId = SessionId("s")
private val stageId = StageId("st")
@Test
fun `SourceFetchedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = SourceFetchedEvent(
sessionId = sessionId,
stageId = stageId,
url = "https://example.com/a",
contentSha256 = "abc123",
quality = "OK",
byteCount = 4096,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"SourceFetched\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `LowQualityExtractionEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = LowQualityExtractionEvent(
sessionId = sessionId,
stageId = stageId,
url = "https://example.com/spa",
contentSha256 = "def456",
reason = "extracted 12 chars, below minimum 200",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"LowQualityExtraction\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,42 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StageCheckpointEventSerializationTest {
@Test
fun `StageCheckpointPassedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointPassedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json"),
producedArtifacts = setOf("plan.json", "notes.md"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointPassed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `StageCheckpointFailedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointFailedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json", "diff.patch"),
producedArtifacts = setOf("plan.json"),
missingArtifacts = setOf("diff.patch"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointFailed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,68 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
class EgressAllowlistProjectionTest {
private val sessionId = SessionId("s1")
private val projection = EgressAllowlistProjection(sessionId)
private fun stored(payload: EventPayload, eventId: String = "e1", session: SessionId = sessionId) =
StoredEvent(
metadata = EventMetadata(
eventId = EventId(eventId),
sessionId = session,
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = 1L,
sessionSequence = 1L,
payload = payload,
)
private fun fold(vararg events: StoredEvent): Set<String> =
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
@Test
fun `initial state is empty`() {
assertEquals(emptySet(), projection.initial())
}
@Test
fun `grants fold into a union`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("a.com", "b.com")), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("b.com", "c.com")), "e2"),
)
assertEquals(setOf("a.com", "b.com", "c.com"), result)
}
@Test
fun `grants for other sessions are ignored`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("mine.com")), "e1"),
stored(EgressHostsGrantedEvent(SessionId("other"), setOf("theirs.com")), "e2"),
)
assertEquals(setOf("mine.com"), result)
}
@Test
fun `unrelated events are ignored`() {
val result = fold(
stored(InitialIntentEvent(sessionId, "do a thing"), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("kept.com")), "e2"),
)
assertEquals(setOf("kept.com"), result)
}
}