diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a0ae8df..25fdf44 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -129,12 +129,13 @@ store, including case variants and resolved symlink aliases. ## Good contribution areas -The [Project / Asset Model v1 proposal](PROJECT_ASSET_MODEL_V1.md) defines the next -identity/evidence contract, producer boundaries and staged migration. It is design intent -for review, not an implemented common model. Its contract separates collection outcomes, -positive evidence with explicit provenance references, and assessments. The first migration -must produce evidence from a real Work input while preserving the existing UI identity; -the execution boundaries above remain authoritative. +The [Project / Asset Model v1 contract](PROJECT_ASSET_MODEL_V1.md) separates collection +outcomes, positive evidence with explicit provenance references, and assessments. Phase 1 +implements pure identity/evidence/snapshot types in `ModoreDomain` and one Work adapter. +Recorded workspace claims retain their session collection run; inferred project mappings +have a separate composition run that changes only when a builder input is published. +Work keeps its legacy IDs, equality and UI behavior. Storage/runtime integration remains +planned, and the execution boundaries above remain authoritative. - Verified Korean/Japanese application whitelist entries. - False-positive fixtures that contain no personal scan data. diff --git a/docs/PROJECT_ASSET_MODEL_V1.md b/docs/PROJECT_ASSET_MODEL_V1.md index 34b6fb1..e8d13b2 100644 --- a/docs/PROJECT_ASSET_MODEL_V1.md +++ b/docs/PROJECT_ASSET_MODEL_V1.md @@ -1,7 +1,8 @@ # Project / Asset Model v1 -Status: **Design intent — review before implementation.** The Swift declarations below -describe the first migration contract; they are not currently shipped APIs. +Status: **Phase 1 implemented; later migrations remain design intent.** The declarations +below summarize the contract. The validated public APIs live in `shared/ModoreDomain`; +the Work observations and adapter live in the Mac app. Storage/runtime integration remains planned. Modore explains the structure and provenance of durable local state, supplies evidence for protection and recovery decisions, and revalidates every action at a separate @@ -55,7 +56,7 @@ public struct AssetIdentity: Hashable, Codable, Sendable { } public enum EvidenceSource: String, Codable, Sendable { - case scree, repositoryAssessment, storageScan, processObservation + case scree, projectAttribution, repositoryAssessment, storageScan, processObservation case fileAccess, sessionBackup, cleanupReceipt, recoveryHistory } @@ -86,7 +87,7 @@ public struct Coverage: Equatable, Codable, Sendable { } public enum EvidenceMethod: String, Codable, Sendable { - case recordedWorkspace, knownRootAncestry, conventionalWorktreePath + case recordedWorkspace, knownRootAncestry, conventionalWorktreePath, workspaceFallback case gitRegistry, pathAncestry, projectManifest case processWorkingDirectory, processOpenFile case transcriptReadInvocation, transcriptWriteInvocation, transcriptShellReference @@ -111,7 +112,11 @@ public struct ProducerFailure: Equatable, Codable, Sendable { public let detail: String // Display only; never an assessment input. } -public struct ProducerSnapshot: Codable, Sendable { +public protocol EvidenceBackedRecord: Codable, Sendable { + var evidence: EvidenceRecord { get } +} + +public struct ProducerSnapshot: Codable, Sendable { public let producer: EvidenceSource public let runID: String public let schemaVersion: Int @@ -233,20 +238,27 @@ executable plan or validated target. ## Producer contract Keep producer implementations and composition in app `Services/`; put only pure schema -and semantics in `shared/ModoreDomain`. The first Work adapter consumes an actual -`SessionIndexEntry` and the existing builder's resolved project key. It emits a typed Work -workspace observation with the logical session identity, recorded workspace, project identity -and `EvidenceRecord` using `recordedWorkspace + recordedClaim`. If the builder maps that -workspace to an ancestor or conventional worktree root, represent that mapping separately -as inferred evidence rather than asserting the session recorded that root. Reuse the -builder's result and matching rule, without changing its grouping algorithm. Missing -workspace produces no membership evidence and stays in the existing unassigned bucket. - -Phase 1 uses `ProducerSnapshot` at this adapter boundary. -The existing collection invocation supplies a run ID that survives re-adaptation/rendering; -the adapter derives record IDs deterministically within that run. It does not request -another scan or invent missing timestamps. Observation payloads and adapter code stay in -the app; only the envelope and pure value types live in `ModoreDomain`. +and semantics in `shared/ModoreDomain`. The Work adapter emits separate records: +`WorkWorkspaceObservation` carries the provider's recorded workspace with `.scree` provenance; +`WorkProjectAttribution` carries the builder's inferred project mapping with +`.projectAttribution` provenance and its input observation. An unknown workspace produces +neither and stays in the unassigned bucket. Physical artifact identity remains explicit +when no logical provider ID exists. + +Each accepted session-index publication gets a collection run ID. Each publication of any +builder input (session index, scree report, repository assessments, failures or unscanned roots) +also gets a new composition run ID and retains the input revision tuple. Root-only changes +never relabel the session observation. `WorkProvenance` creates IDs at these publication +boundaries, not in `workProjects`, adapters or SwiftUI bodies. Task generations remain +separate stale-write controls. Failed/cancelled loads cannot re-stamp retained facts. + +The adapter emits separate `ProducerSnapshot` and +`ProducerSnapshot` envelopes. Record IDs identify input positions +within immutable runs; repeated adaptation retains references. The collector timestamp is +unknown when absent from the DTO; composition time describes the actual inference. The +existing builder supplies both the root and the matching rule, including an explicitly +inferred workspace fallback. Work rows attach the result without changing their legacy +String IDs, equality, sorting, selection or action behavior. Snapshots distinguish collection outcome from positive records. Failed/not-collected/unknown attempts emit no current positive records; recovered valid records from a run interrupted by diff --git a/macos/Modore/Sources/Modore/Models/WorkEvidence.swift b/macos/Modore/Sources/Modore/Models/WorkEvidence.swift new file mode 100644 index 0000000..303dea9 --- /dev/null +++ b/macos/Modore/Sources/Modore/Models/WorkEvidence.swift @@ -0,0 +1,40 @@ +import Foundation +import ModoreDomain + +/// Revisions of the published inputs to WorkProjectBuilder, not task generations. +enum WorkInput: String, Codable, Sendable { case sessionIndex, screeReport, repoAssessments, repoScanFailures, reposNotScanned } + +struct WorkProvenance { + private(set) var inputRuns: [WorkInput: String] = [:] + private(set) var compositionRunID = UUID().uuidString + private(set) var composedAt = Date() + + /// Called only when an input is published, never by a getter or SwiftUI body. + /// Even a same-valued newly published snapshot is a distinct observation run. + mutating func didPublish(_ input: WorkInput) { + inputRuns[input] = UUID().uuidString + compositionRunID = UUID().uuidString + composedAt = Date() + } +} + +/// What the provider recorded. Deliberately contains no inferred project identity. +struct WorkWorkspaceObservation: EvidenceBackedRecord, Equatable { + let session: AssetIdentity + let recordedWorkspace: String + let evidence: EvidenceRecord +} + +/// What Modore infers from the currently published combination of Work inputs. +/// The input claim keeps its original collection reference and timestamp. +struct WorkProjectAttribution: EvidenceBackedRecord, Equatable { + let workspace: WorkWorkspaceObservation + let project: ProjectIdentity + let inputRuns: [WorkInput: String] + let evidence: EvidenceRecord +} + +struct WorkEvidence { + let metadata: ProducerSnapshot? + let attribution: ProducerSnapshot +} diff --git a/macos/Modore/Sources/Modore/Models/WorkModels.swift b/macos/Modore/Sources/Modore/Models/WorkModels.swift index b50a48d..2f348b7 100644 --- a/macos/Modore/Sources/Modore/Models/WorkModels.swift +++ b/macos/Modore/Sources/Modore/Models/WorkModels.swift @@ -1,5 +1,6 @@ import Foundation import MothballCore +import ModoreDomain /// One title for one session, as `scree.py titles` returns it. struct SessionTitle: Decodable, Equatable { @@ -30,6 +31,8 @@ struct WorkProject: Identifiable { let path: String var sessions: [SessionIndexEntry] = [] var worktrees: [ScreeWorktreeItem] = [] + /// Display-only evidence; deliberately excluded from legacy row equality and IDs. + var workspaceAttributions: [WorkProjectAttribution] = [] /// What is known about this project's git state -- including the two /// cases where the answer is that nothing is known. @@ -58,6 +61,10 @@ struct WorkProject: Identifiable { var id: String { WorkProjectBuilder.canonical(path) } + var identity: ProjectIdentity? { + isUnassigned ? nil : try? ProjectIdentity(comparisonKey: id) + } + /// Conversations that exist but could not be placed in a project. var isUnassigned: Bool { path == WorkProjectBuilder.unassignedID } @@ -258,10 +265,7 @@ enum WorkProjectBuilder { scanFailures: [String: String] = [:], notScanned: [String] = [] ) -> [WorkProject] { - var roots: Set = [] - for assessment in assessments { roots.insert(canonical(assessment.pathText)) } - for worktree in worktrees { roots.insert(canonical(worktree.repo)) } - for root in gitRoots { roots.insert(canonical(root)) } + let roots = knownRoots(worktrees: worktrees, assessments: assessments, gitRoots: gitRoots) var projects: [String: WorkProject] = [:] // Case-folded key, real spelling for display. macOS filesystems are @@ -324,6 +328,15 @@ enum WorkProjectBuilder { } } + static func knownRoots(worktrees: [ScreeWorktreeItem], assessments: [ArchiveCandidate], + gitRoots: [String]) -> Set { + var roots: Set = [] + for assessment in assessments { roots.insert(canonical(assessment.pathText)) } + for worktree in worktrees { roots.insert(canonical(worktree.repo)) } + for root in gitRoots { roots.insert(canonical(root)) } + return roots + } + /// The project a workspace belongs to. /// /// Agent worktrees are folded into their repo even when no scanner has @@ -332,6 +345,12 @@ enum WorkProjectBuilder { /// confirm it would leave the list looking like a pile of adjectives /// and surnames. static func projectRoot(for workspace: String, roots: Set) -> String { + projectMapping(for: workspace, roots: roots).path + } + + /// The same legacy algorithm, returning the rule used as well as its result. + /// This is attribution evidence, never a second filesystem/Git observation. + static func projectMapping(for workspace: String, roots: Set) -> (path: String, method: EvidenceMethod) { let normalized = normalize(workspace) let folded = canonical(workspace) var best: String? @@ -346,15 +365,15 @@ enum WorkProjectBuilder { if let best { // Return the caller's own spelling when the match is the whole // path, so a project keeps the casing it was recorded with. - return folded == best ? normalized : String(normalized.prefix(best.count)) + return (folded == best ? normalized : String(normalized.prefix(best.count)), .knownRootAncestry) } for marker in ["/.claude/worktrees/", "/.git/worktrees/"] { if let range = folded.range(of: marker) { - return String(normalized.prefix(folded.distance( - from: folded.startIndex, to: range.lowerBound))) + return (String(normalized.prefix(folded.distance( + from: folded.startIndex, to: range.lowerBound))), .conventionalWorktreePath) } } - return normalized + return (normalized, .workspaceFallback) } /// The comparison key: trailing slash removed, case folded. diff --git a/macos/Modore/Sources/Modore/Services/MothballService.swift b/macos/Modore/Sources/Modore/Services/MothballService.swift index ee906f1..e988613 100644 --- a/macos/Modore/Sources/Modore/Services/MothballService.swift +++ b/macos/Modore/Sources/Modore/Services/MothballService.swift @@ -360,18 +360,33 @@ extension ScanModel { /// Everything the Work screen shows, assembled from the three /// scanners that used to own a screen each. var workProjects: [WorkProject] { - WorkProjectBuilder.build( + let worktrees = screeReport?.worktreeItems ?? [] + let assessments = repoAssessments ?? [] + let gitRoots = (screeReport?.lineagePaths ?? []).filter { $0.hasGit == true }.map(\.path) + var projects = WorkProjectBuilder.build( sessions: sessionIndex?.sessions ?? [], - worktrees: screeReport?.worktreeItems ?? [], - assessments: repoAssessments ?? [], + worktrees: worktrees, + assessments: assessments, // Every git path the audit saw, so project identity does not // depend on which repos survived the archive classifier or the // scanner's own root limit. - gitRoots: (screeReport?.lineagePaths ?? []) - .filter { $0.hasGit == true }.map(\.path), + gitRoots: gitRoots, scanFailures: repoScanFailures, notScanned: reposNotScanned ) + // Evidence cannot influence the established UI grouping, equality, selection + // or action paths. An adaptation failure leaves those rows intact. + if let evidence = try? WorkWorkspaceAdapter.adapt( + index: sessionIndex, + roots: WorkProjectBuilder.knownRoots(worktrees: worktrees, assessments: assessments, gitRoots: gitRoots), + provenance: workProvenance + ) { + let byProject = Dictionary(grouping: evidence.attribution.records, by: { $0.project.comparisonKey }) + for index in projects.indices { + projects[index].workspaceAttributions = byProject[projects[index].id] ?? [] + } + } + return projects } /// Loads what the Work screen needs the moment someone opens it. diff --git a/macos/Modore/Sources/Modore/Services/ScanModel.swift b/macos/Modore/Sources/Modore/Services/ScanModel.swift index 9d8f016..7937cdb 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModel.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModel.swift @@ -205,7 +205,10 @@ final class ScanModel: ObservableObject { @Published private(set) var deepScanFailure: DeepScanFailure? @Published private(set) var deepScanAt: Date? @Published private(set) var cleanupMutationPending = false - @Published var screeReport: ScreeReport? + private(set) var workProvenance = WorkProvenance() + @Published var screeReport: ScreeReport? { + didSet { workProvenance.didPublish(.screeReport) } + } @Published var screeReportRevision = 0 @Published var screeLoading = false @Published var screeError: String? @@ -222,12 +225,18 @@ final class ScanModel: ObservableObject { /// Every repo the scan judged, archivable or not. Named for what it /// is: `rankCandidates` drops `.unsafe` repos, which is right for a /// retirement list and wrong as a project's git state. - @Published var repoAssessments: [ArchiveCandidate]? + @Published var repoAssessments: [ArchiveCandidate]? { + didSet { workProvenance.didPublish(.repoAssessments) } + } /// Repo path to why it could not be read, and the repos the scan's /// root cap never reached. Both travel per path: a total says /// something somewhere is untrustworthy, only a path says which row. - @Published var repoScanFailures: [String: String] = [:] - @Published var reposNotScanned: [String] = [] + @Published var repoScanFailures: [String: String] = [:] { + didSet { workProvenance.didPublish(.repoScanFailures) } + } + @Published var reposNotScanned: [String] = [] { + didSet { workProvenance.didPublish(.reposNotScanned) } + } /// Conversations a person opened, and how each fetch went, keyed by /// the transcript's byte identity rather than its path -- see /// `loadConversation`. Display-only cache; never consulted by any @@ -235,7 +244,9 @@ final class ScanModel: ObservableObject { @Published var conversationLoads: [String: ConversationLoadState] = [:] var conversationLoadTokens: [String: UUID] = [:] /// The session browser's index, and how fetching it went. - @Published var sessionIndex: SessionIndex? + @Published var sessionIndex: SessionIndex? { + didSet { workProvenance.didPublish(.sessionIndex) } + } @Published var sessionIndexLoading = false @Published var sessionIndexError: String? var sessionIndexGeneration = 0 diff --git a/macos/Modore/Sources/Modore/Services/WorkWorkspaceAdapter.swift b/macos/Modore/Sources/Modore/Services/WorkWorkspaceAdapter.swift new file mode 100644 index 0000000..4ee6c8c --- /dev/null +++ b/macos/Modore/Sources/Modore/Services/WorkWorkspaceAdapter.swift @@ -0,0 +1,75 @@ +import Foundation +import ModoreDomain + +/// Pure adaptation of immutable published inputs. No filesystem reads, UUID creation, +/// action eligibility, or approval state. The existing builder owns the mapping rule. +enum WorkWorkspaceAdapter { + static func adapt(index: SessionIndex?, roots: Set, provenance: WorkProvenance) throws -> WorkEvidence { + let attributionCoverage = Coverage( + state: .unknown, scope: "Current Work workspace attribution", + limitations: ["Legacy case-folded mapping; repository and filesystem coverage are not established."]) + guard let index, let sessionRun = provenance.inputRuns[.sessionIndex] else { + return WorkEvidence(metadata: nil, attribution: try ProducerSnapshot( + producer: .projectAttribution, runID: provenance.compositionRunID, + observedAt: provenance.composedAt, outcome: .notCollected, + coverage: attributionCoverage, records: [])) + } + + let complete = index.coverage.complete && index.total == index.sessions.count + let coverageState: CoverageState = complete ? .complete + : (index.total != index.sessions.count || !index.coverage.stores.isEmpty ? .partial : .unknown) + let metadataCoverage = Coverage( + state: coverageState, scope: "Returned session-index workspace metadata", + limitations: complete ? [] : ["Session discovery or returned rows are incomplete; no absence claim."]) + var observations: [WorkWorkspaceObservation] = [] + var attributions: [WorkProjectAttribution] = [] + var failures: [ProducerFailure] = [] + for (offset, session) in index.sessions.enumerated() { + // Unknown workspace is an unassigned UI bucket, never a factual membership. + guard !session.workspace.isEmpty else { continue } + let provider = session.tool.lowercased() + let logicalID = session.providerSessionId.flatMap { $0.isEmpty ? nil : $0 } + let identity = logicalID ?? session.source + guard !provider.isEmpty, !identity.isEmpty else { + failures.append(ProducerFailure(code: .malformed, recordID: "\(offset)", + detail: "Session identity is missing.")) + continue + } + // Length framing keeps provider/key tuples unambiguous. Physical fallback + // never pretends that a provider lacking a logical ID supplied one. + let asset = try AssetIdentity(namespace: logicalID == nil ? "session-artifact" : "logical-session", + key: "\(provider.utf8.count):\(provider)\(identity)") + let observation = WorkWorkspaceObservation( + session: asset, recordedWorkspace: session.workspace, + evidence: try EvidenceRecord( + reference: EvidenceReference(source: .scree, runID: sessionRun, recordID: "workspace:\(offset)"), + method: .recordedWorkspace, basis: .recordedClaim, + // SessionIndex carries no collector timestamp. lastActive is an + // event timestamp and must not impersonate observation time. + observedAt: nil, coverage: metadataCoverage)) + observations.append(observation) + let mapping = WorkProjectBuilder.projectMapping(for: session.workspace, roots: roots) + attributions.append(WorkProjectAttribution( + workspace: observation, + project: try ProjectIdentity(comparisonKey: WorkProjectBuilder.canonical(mapping.path)), + inputRuns: provenance.inputRuns, + evidence: try EvidenceRecord( + reference: EvidenceReference(source: .projectAttribution, + runID: provenance.compositionRunID, recordID: "attribution:\(offset)"), + method: mapping.method, basis: .inferred, + observedAt: provenance.composedAt, coverage: attributionCoverage))) + } + let outcome: CollectionOutcome = complete && failures.isEmpty ? .complete : .partial + let snapshotCoverage = failures.isEmpty ? metadataCoverage : Coverage( + state: .partial, scope: metadataCoverage.scope, + limitations: metadataCoverage.limitations + ["Invalid session identities could not be adapted."]) + return WorkEvidence( + metadata: try ProducerSnapshot(producer: .scree, runID: sessionRun, observedAt: nil, + outcome: outcome, coverage: snapshotCoverage, + records: observations, failures: failures), + attribution: try ProducerSnapshot(producer: .projectAttribution, + runID: provenance.compositionRunID, + observedAt: provenance.composedAt, outcome: outcome, + coverage: attributionCoverage, records: attributions)) + } +} diff --git a/macos/Modore/Tests/ModoreTests/WorkEvidenceTests.swift b/macos/Modore/Tests/ModoreTests/WorkEvidenceTests.swift new file mode 100644 index 0000000..6ec13f3 --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/WorkEvidenceTests.swift @@ -0,0 +1,205 @@ +import XCTest +import ModoreDomain +@testable import Modore + +final class WorkWorkspaceAdapterTests: XCTestCase { + func testMetadataAndAttributionHaveSeparateProvenanceAndClaims() throws { + let session = SessionInspectionFixtures.entry(workspace: "/repo/.claude/worktrees/x") + let index = SessionIndex(total: 1, sessions: [session]) + var provenance = WorkProvenance() + provenance.didPublish(.sessionIndex) + let first = try WorkWorkspaceAdapter.adapt(index: index, roots: [], provenance: provenance) + let metadata = try XCTUnwrap(first.metadata?.records.first) + let attribution = try XCTUnwrap(first.attribution.records.first) + XCTAssertEqual(metadata.recordedWorkspace, session.workspace) + XCTAssertEqual(metadata.evidence.reference.source, .scree) + XCTAssertEqual(metadata.evidence.method, .recordedWorkspace) + XCTAssertEqual(metadata.evidence.basis, .recordedClaim) + XCTAssertNil(metadata.evidence.observedAt) + XCTAssertEqual(metadata.evidence.coverage.state, .unknown) + XCTAssertEqual(attribution.workspace, metadata) + XCTAssertEqual(attribution.project.comparisonKey, "/repo") + XCTAssertEqual(attribution.evidence.reference.source, .projectAttribution) + XCTAssertEqual(attribution.evidence.method, .conventionalWorktreePath) + XCTAssertEqual(attribution.evidence.basis, .inferred) + XCTAssertNotEqual(attribution.evidence.reference.runID, metadata.evidence.reference.runID) + + let repeated = try WorkWorkspaceAdapter.adapt(index: index, roots: [], provenance: provenance) + XCTAssertEqual(repeated.metadata, first.metadata) + XCTAssertEqual(repeated.attribution, first.attribution) + + // The same recorded workspace now matches a more specific established root. + provenance.didPublish(.screeReport) + let changed = try WorkWorkspaceAdapter.adapt(index: index, roots: [session.workspace], provenance: provenance) + XCTAssertEqual(changed.metadata, first.metadata) + XCTAssertEqual(changed.attribution.records.first?.project.comparisonKey, session.workspace) + XCTAssertEqual(changed.attribution.records.first?.evidence.method, .knownRootAncestry) + XCTAssertNotEqual(changed.attribution.runID, first.attribution.runID) + XCTAssertEqual(attribution.project.comparisonKey, "/repo", "Previously emitted value stays immutable") + + provenance.didPublish(.sessionIndex) + let recollected = try WorkWorkspaceAdapter.adapt(index: index, roots: [session.workspace], provenance: provenance) + XCTAssertNotEqual(recollected.metadata?.runID, changed.metadata?.runID) + XCTAssertNotEqual(recollected.attribution.runID, changed.attribution.runID) + } + + func testEmptyWorkspaceAndMissingCollectionHaveNoEvidence() throws { + var provenance = WorkProvenance() + let uncollected = try WorkWorkspaceAdapter.adapt(index: nil, roots: [], provenance: provenance) + XCTAssertNil(uncollected.metadata) + XCTAssertEqual(uncollected.attribution.outcome, .notCollected) + XCTAssertTrue(uncollected.attribution.records.isEmpty) + provenance.didPublish(.sessionIndex) + let index = SessionIndex(total: 1, sessions: [SessionInspectionFixtures.entry(workspace: "")]) + let result = try WorkWorkspaceAdapter.adapt(index: index, roots: [], provenance: provenance) + XCTAssertTrue(try XCTUnwrap(result.metadata).records.isEmpty) + XCTAssertTrue(result.attribution.records.isEmpty) + let project = try XCTUnwrap(WorkProjectBuilder.build(sessions: index.sessions, worktrees: [], assessments: []).first) + XCTAssertEqual(project.id, WorkProjectBuilder.unassignedID) + XCTAssertNil(project.identity) + } + + func testCoverageAndInvalidIdentityDoNotBecomeFalseFacts() throws { + let good = SessionInspectionFixtures.entry(workspace: "/repo") + let invalid = SessionInspectionFixtures.entry(workspace: "/other", source: "") + let index = SessionIndex(total: 10, sessions: [good, invalid], + coverage: SessionIndexCoverage(complete: true, stores: [])) + var provenance = WorkProvenance() + provenance.didPublish(.sessionIndex) + let result = try WorkWorkspaceAdapter.adapt(index: index, roots: [], provenance: provenance) + XCTAssertEqual(result.metadata?.outcome, .partial) + XCTAssertEqual(result.metadata?.coverage.state, .partial) + XCTAssertEqual(result.metadata?.failures.first?.code, .malformed) + XCTAssertEqual(result.metadata?.records.count, 1) + XCTAssertEqual(result.attribution.records.count, 1) + XCTAssertEqual(result.attribution.coverage.state, .unknown) + XCTAssertEqual(result.attribution.records.first?.evidence.method, .workspaceFallback) + } + + func testLogicalSessionIdentityIsDistinctFromPhysicalFallback() throws { + let json: [String: Any] = [ + "tool": "Codex", "source": "/transcripts/a.jsonl", "workspace": "/repo", + "kind": "session", "sizeBytes": 10, "lastActive": "2026-09-08", + "sessionId": "logical-1", "artifactSources": ["/transcripts/a.jsonl", "/transcripts/b.jsonl"], + ] + let logical = try JSONDecoder().decode(SessionIndexEntry.self, from: JSONSerialization.data(withJSONObject: json)) + let physical = SessionInspectionFixtures.entry(tool: "Codex", workspace: "/repo", source: "logical-1") + var provenance = WorkProvenance() + provenance.didPublish(.sessionIndex) + let result = try WorkWorkspaceAdapter.adapt(index: SessionIndex(total: 2, sessions: [logical, physical]), + roots: [], provenance: provenance) + let records = try XCTUnwrap(result.metadata).records + XCTAssertEqual(records[0].session.namespace, "logical-session") + XCTAssertEqual(records[1].session.namespace, "session-artifact") + XCTAssertNotEqual(records[0].session, records[1].session) + } +} + +@MainActor +final class WorkEvidencePublicationTests: XCTestCase { + private func model() async -> ScanModel { + let model = ScanModel(automaticallyScansStaleResults: false) + for task in model.cancelTrackedApplicationTasks() { await task.value } + return model + } + + func testComputedProjectsPreserveRowsSelectionAndStableReferences() async throws { + let model = await model() + let sessions = [ + SessionInspectionFixtures.entry(workspace: "/Repo/.claude/worktrees/x", lastActive: "2026-09-08"), + SessionInspectionFixtures.entry(workspace: "/Repo/", lastActive: "2026-09-07"), + SessionInspectionFixtures.entry(workspace: ""), + SessionInspectionFixtures.entry(workspace: "/repo-sibling", lastActive: "2026-09-06"), + ] + model.sessionIndex = SessionIndex(total: sessions.count, sessions: sessions) + model.selectedProjectID = "/repo" + model.selectedSessionSource = sessions[0].source + let legacy = WorkProjectBuilder.build(sessions: sessions, worktrees: [], assessments: []) + let first = model.workProjects + XCTAssertEqual(first, legacy) + XCTAssertEqual(first.map(\.id), ["/repo", "/repo-sibling", WorkProjectBuilder.unassignedID]) + XCTAssertEqual(first.map(\.name), legacy.map(\.name)) + XCTAssertEqual(first.map(\.sizeText), legacy.map(\.sizeText)) + XCTAssertEqual(first.map(\.conversationCount), legacy.map(\.conversationCount)) + XCTAssertEqual(WorkListPane.filter(first, search: "repo"), WorkListPane.filter(legacy, search: "repo")) + XCTAssertEqual(first[0].identity?.comparisonKey, "/repo") + XCTAssertEqual(first[0].workspaceAttributions.count, 2) + XCTAssertNil(first.last?.identity) + XCTAssertTrue(first.last?.workspaceAttributions.isEmpty == true) + let refs = first.flatMap(\.workspaceAttributions).map(\.evidence.reference) + for _ in 0..<5 { + XCTAssertEqual(model.workProjects.flatMap(\.workspaceAttributions).map(\.evidence.reference), refs) + } + model.sessionIndexGeneration += 1 + model.sessionSearch = "repo" + XCTAssertEqual(model.workProjects.flatMap(\.workspaceAttributions).map(\.evidence.reference), refs) + XCTAssertEqual(model.selectedProjectID, "/repo") + XCTAssertEqual(model.selectedSessionSource, sessions[0].source) + } + + func testRootPublicationChangesOnlyCompositionProvenance() async throws { + let model = await model() + let session = SessionInspectionFixtures.entry(workspace: "/repo/nested/subdir") + model.sessionIndex = SessionIndex(total: 1, sessions: [session]) + let before = try XCTUnwrap(model.workProjects.first?.workspaceAttributions.first) + model.screeReport = ScreeReport(json: ["lineage": ["paths": [ + ["path": "/repo", "has_git": true], ["path": "/repo/nested", "has_git": true], + ]]]) + let after = try XCTUnwrap(model.workProjects.first?.workspaceAttributions.first) + XCTAssertEqual(before.project.comparisonKey, session.workspace) + XCTAssertEqual(after.project.comparisonKey, "/repo/nested") + XCTAssertEqual(before.workspace, after.workspace) + XCTAssertNotEqual(before.evidence.reference, after.evidence.reference) + XCTAssertEqual(after.inputRuns, model.workProvenance.inputRuns) + let repeated = try XCTUnwrap(model.workProjects.first?.workspaceAttributions.first) + XCTAssertEqual(after, repeated) + } + + func testEveryBuilderInputPublicationRotatesCompositionButNotSessionRun() async throws { + let model = await model() + model.sessionIndex = SessionIndex(total: 1, sessions: [SessionInspectionFixtures.entry(workspace: "/repo")]) + let sessionRun = model.workProvenance.inputRuns[.sessionIndex] + let updates: [(WorkInput, () -> Void)] = [ + (.screeReport, { model.screeReport = ScreeReport(json: [:]) }), + (.repoAssessments, { model.repoAssessments = [] }), + (.repoScanFailures, { model.repoScanFailures["/repo"] = "unreadable" }), + (.reposNotScanned, { model.reposNotScanned.append("/other") }), + ] + for (input, update) in updates { + let before = model.workProvenance.compositionRunID + update() + XCTAssertNotEqual(model.workProvenance.compositionRunID, before, input.rawValue) + XCTAssertNotNil(model.workProvenance.inputRuns[input]) + XCTAssertEqual(model.workProvenance.inputRuns[.sessionIndex], sessionRun) + } + let before = try XCTUnwrap(model.workProjects.first { $0.path == "/repo" }?.workspaceAttributions.first) + let index = model.sessionIndex + model.sessionIndex = index + let after = try XCTUnwrap(model.workProjects.first { $0.path == "/repo" }?.workspaceAttributions.first) + XCTAssertNotEqual(before.workspace.evidence.reference, after.workspace.evidence.reference) + XCTAssertNotEqual(before.evidence.reference, after.evidence.reference) + } + + func testFailedRefreshDoesNotRelabelRetainedFactsAndCancelledWriteCannotRotateRuns() async throws { + let model = await model() + let index = SessionIndex(total: 1, sessions: [SessionInspectionFixtures.entry(workspace: "/repo")]) + model.refreshSessionIndex(using: { .success(index) }) + await model.sessionIndexTask?.value + let first = try XCTUnwrap(model.workProjects.first?.workspaceAttributions.first) + model.refreshSessionIndex(using: { .failure(.init(message: "unreadable")) }) + await model.sessionIndexTask?.value + XCTAssertEqual(model.sessionIndexError, "unreadable") + XCTAssertEqual(model.workProjects.first?.workspaceAttributions.first, first) + + // Supersede before the first task can publish. Generation stays a lifecycle + // token, while only the accepted publication advances semantic provenance. + model.refreshSessionIndex(using: { .success(SessionIndex(total: 0, sessions: [])) }) + let old = model.sessionIndexTask + model.refreshSessionIndex(using: { .success(index) }) + await model.sessionIndexTask?.value + let accepted = try XCTUnwrap(model.workProjects.first?.workspaceAttributions.first) + await old?.value + XCTAssertEqual(model.workProjects.first?.workspaceAttributions.first, accepted) + XCTAssertNotEqual(first.workspace.evidence.reference, accepted.workspace.evidence.reference) + } +} diff --git a/shared/ModoreDomain/Sources/ModoreDomain/Evidence.swift b/shared/ModoreDomain/Sources/ModoreDomain/Evidence.swift new file mode 100644 index 0000000..feb415d --- /dev/null +++ b/shared/ModoreDomain/Sources/ModoreDomain/Evidence.swift @@ -0,0 +1,96 @@ +import Foundation + +public enum EvidenceSource: String, Codable, Sendable { + case scree, projectAttribution, repositoryAssessment, storageScan, processObservation + case fileAccess, sessionBackup, cleanupReceipt, recoveryHistory +} + +public struct EvidenceReference: Hashable, Codable, Sendable { + public let source: EvidenceSource + public let runID: String + public let recordID: String + + public init(source: EvidenceSource, runID: String, recordID: String) throws { + guard !runID.isEmpty, !recordID.isEmpty else { throw EvidenceContractError.emptyIdentity } + self.source = source + self.runID = runID + self.recordID = recordID + } + + private enum CodingKeys: String, CodingKey { case source, runID, recordID } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(source: values.decode(EvidenceSource.self, forKey: .source), + runID: values.decode(String.self, forKey: .runID), + recordID: values.decode(String.self, forKey: .recordID)) + } +} + +/// Positive support for the containing record's claim, never a collection result. +public enum EvidenceBasis: String, Codable, Sendable { + case observed, inferred, recordedClaim, reference +} + +public enum CoverageState: String, Codable, Sendable { case complete, partial, unknown } + +public struct Coverage: Equatable, Codable, Sendable { + public let state: CoverageState + /// Opaque display descriptions. Never parse these strings to make a judgment. + public let scope: String + public let limitations: [String] + + public init(state: CoverageState, scope: String, limitations: [String] = []) { + self.state = state + self.scope = scope + self.limitations = limitations + } +} + +public enum EvidenceMethod: String, Codable, Sendable { + case recordedWorkspace, knownRootAncestry, conventionalWorktreePath, workspaceFallback + case gitRegistry, pathAncestry, projectManifest + case processWorkingDirectory, processOpenFile + case transcriptReadInvocation, transcriptWriteInvocation, transcriptShellReference + case archiveHashVerification, cleanupReceipt, recoveryCheckpoint + + public var basis: EvidenceBasis { + switch self { + case .recordedWorkspace, .transcriptReadInvocation, .transcriptWriteInvocation: + return .recordedClaim + case .knownRootAncestry, .conventionalWorktreePath, .workspaceFallback, .pathAncestry: + return .inferred + case .transcriptShellReference: return .reference + default: return .observed + } + } +} + +/// Provenance only. A typed containing record supplies the subject and factual claim. +/// There is deliberately no failed/unknown evidence or execution capability here. +public struct EvidenceRecord: Equatable, Codable, Sendable { + public let reference: EvidenceReference + public let method: EvidenceMethod + public let basis: EvidenceBasis + public let observedAt: Date? + public let coverage: Coverage + + public init(reference: EvidenceReference, method: EvidenceMethod, basis: EvidenceBasis, + observedAt: Date?, coverage: Coverage) throws { + guard basis == method.basis else { throw EvidenceContractError.invalidMethodBasis } + self.reference = reference + self.method = method + self.basis = basis + self.observedAt = observedAt + self.coverage = coverage + } + + private enum CodingKeys: String, CodingKey { case reference, method, basis, observedAt, coverage } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(reference: values.decode(EvidenceReference.self, forKey: .reference), + method: values.decode(EvidenceMethod.self, forKey: .method), + basis: values.decode(EvidenceBasis.self, forKey: .basis), + observedAt: values.decodeIfPresent(Date.self, forKey: .observedAt), + coverage: values.decode(Coverage.self, forKey: .coverage)) + } +} diff --git a/shared/ModoreDomain/Sources/ModoreDomain/ProducerSnapshot.swift b/shared/ModoreDomain/Sources/ModoreDomain/ProducerSnapshot.swift new file mode 100644 index 0000000..1ce546f --- /dev/null +++ b/shared/ModoreDomain/Sources/ModoreDomain/ProducerSnapshot.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum CollectionOutcome: String, Codable, Sendable { + case complete, partial, failed, notCollected, unknown +} + +public enum CollectionFailureCode: String, Codable, Sendable { + case unavailable, unreadable, malformed, timedOut, cancelled, unsupported +} + +public struct ProducerFailure: Equatable, Codable, Sendable { + public let code: CollectionFailureCode + public let recordID: String? + /// Display only; never interpreted by assessment logic. + public let detail: String + + public init(code: CollectionFailureCode, recordID: String? = nil, detail: String) { + self.code = code + self.recordID = recordID + self.detail = detail + } +} + +public protocol EvidenceBackedRecord: Codable, Sendable { + var evidence: EvidenceRecord { get } +} + +/// Immutable facts from one run. Failures cannot masquerade as positive records. +public struct ProducerSnapshot: Codable, Sendable { + public let producer: EvidenceSource + public let runID: String + public let schemaVersion: Int + public let observedAt: Date? + public let outcome: CollectionOutcome + public let coverage: Coverage + public let records: [Record] + public let failures: [ProducerFailure] + + public init(producer: EvidenceSource, runID: String, schemaVersion: Int = 1, + observedAt: Date?, outcome: CollectionOutcome, coverage: Coverage, + records: [Record], failures: [ProducerFailure] = []) throws { + guard !runID.isEmpty else { throw EvidenceContractError.emptyIdentity } + guard schemaVersion == 1, + (outcome == .complete || outcome == .partial || records.isEmpty), + (outcome != .complete || failures.isEmpty), + records.allSatisfy({ $0.evidence.reference.source == producer + && $0.evidence.reference.runID == runID }), + Set(records.map { $0.evidence.reference.recordID }).count == records.count + else { throw EvidenceContractError.invalidSnapshot } + self.producer = producer + self.runID = runID + self.schemaVersion = schemaVersion + self.observedAt = observedAt + self.outcome = outcome + self.coverage = coverage + self.records = records + self.failures = failures + } + + private enum CodingKeys: String, CodingKey { + case producer, runID, schemaVersion, observedAt, outcome, coverage, records, failures + } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(producer: values.decode(EvidenceSource.self, forKey: .producer), + runID: values.decode(String.self, forKey: .runID), + schemaVersion: values.decode(Int.self, forKey: .schemaVersion), + observedAt: values.decodeIfPresent(Date.self, forKey: .observedAt), + outcome: values.decode(CollectionOutcome.self, forKey: .outcome), + coverage: values.decode(Coverage.self, forKey: .coverage), + records: values.decode([Record].self, forKey: .records), + failures: values.decode([ProducerFailure].self, forKey: .failures)) + } +} + +extension ProducerSnapshot: Equatable where Record: Equatable {} diff --git a/shared/ModoreDomain/Sources/ModoreDomain/ProjectIdentity.swift b/shared/ModoreDomain/Sources/ModoreDomain/ProjectIdentity.swift new file mode 100644 index 0000000..f62575a --- /dev/null +++ b/shared/ModoreDomain/Sources/ModoreDomain/ProjectIdentity.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum EvidenceContractError: Error, Equatable { + case emptyIdentity + case invalidMethodBasis + case invalidSnapshot +} + +/// A comparison key supplied by a platform adapter, not a verified filesystem target. +public struct ProjectIdentity: Hashable, Codable, Sendable { + public let comparisonKey: String + + public init(comparisonKey: String) throws { + guard !comparisonKey.isEmpty else { throw EvidenceContractError.emptyIdentity } + self.comparisonKey = comparisonKey + } + + private enum CodingKeys: String, CodingKey { case comparisonKey } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(comparisonKey: values.decode(String.self, forKey: .comparisonKey)) + } +} + +public struct AssetIdentity: Hashable, Codable, Sendable { + public let namespace: String + public let key: String + + public init(namespace: String, key: String) throws { + guard !namespace.isEmpty, !key.isEmpty else { throw EvidenceContractError.emptyIdentity } + self.namespace = namespace + self.key = key + } + + private enum CodingKeys: String, CodingKey { case namespace, key } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(namespace: values.decode(String.self, forKey: .namespace), + key: values.decode(String.self, forKey: .key)) + } +} diff --git a/shared/ModoreDomain/Tests/ModoreDomainTests/EvidenceTests.swift b/shared/ModoreDomain/Tests/ModoreDomainTests/EvidenceTests.swift new file mode 100644 index 0000000..346835f --- /dev/null +++ b/shared/ModoreDomain/Tests/ModoreDomainTests/EvidenceTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import ModoreDomain + +final class EvidenceTests: XCTestCase { + private struct Observation: EvidenceBackedRecord, Equatable { + let evidence: EvidenceRecord + } + private let coverage = Coverage(state: .unknown, scope: "Display description") + + private func record(run: String = "run", id: String = "record") throws -> Observation { + Observation(evidence: try EvidenceRecord( + reference: EvidenceReference(source: .scree, runID: run, recordID: id), + method: .recordedWorkspace, basis: .recordedClaim, observedAt: nil, coverage: coverage)) + } + + func testInvalidIdentitiesAreRejectedByConstructionAndDecoding() throws { + XCTAssertThrowsError(try ProjectIdentity(comparisonKey: "")) + XCTAssertThrowsError(try AssetIdentity(namespace: "", key: "path")) + XCTAssertThrowsError(try AssetIdentity(namespace: "path", key: "")) + XCTAssertThrowsError(try EvidenceReference(source: .scree, runID: "", recordID: "r")) + XCTAssertThrowsError(try EvidenceReference(source: .scree, runID: "r", recordID: "")) + XCTAssertThrowsError(try JSONDecoder().decode(ProjectIdentity.self, from: Data(#"{"comparisonKey":""}"#.utf8))) + XCTAssertThrowsError(try JSONDecoder().decode(AssetIdentity.self, from: Data(#"{"namespace":"path","key":""}"#.utf8))) + XCTAssertThrowsError(try JSONDecoder().decode(EvidenceReference.self, from: Data(#"{"source":"scree","runID":"r","recordID":""}"#.utf8))) + let project = try ProjectIdentity(comparisonKey: "/Repo/") + XCTAssertEqual(project.comparisonKey, "/Repo/", "Domain must not normalize or inspect OS paths") + XCTAssertEqual(try JSONDecoder().decode(ProjectIdentity.self, from: JSONEncoder().encode(project)), project) + } + + func testRecordedInvocationCannotBecomeObservedSuccessfulAccess() throws { + let reference = try EvidenceReference(source: .fileAccess, runID: "run", recordID: "read") + for method in [EvidenceMethod.transcriptReadInvocation, .transcriptWriteInvocation, .transcriptShellReference] { + XCTAssertThrowsError(try EvidenceRecord(reference: reference, method: method, + basis: .observed, observedAt: nil, coverage: coverage)) + } + let claim = try EvidenceRecord(reference: reference, method: .transcriptReadInvocation, + basis: .recordedClaim, observedAt: nil, coverage: coverage) + let encoded = try JSONEncoder().encode(claim) + XCTAssertEqual(try JSONDecoder().decode(EvidenceRecord.self, from: encoded), claim) + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + for invalid in ["observed", "failed", "unknown", "notCollected"] { + json["basis"] = invalid + XCTAssertThrowsError(try JSONDecoder().decode(EvidenceRecord.self, + from: JSONSerialization.data(withJSONObject: json))) + } + } + + func testFailureAndUncollectedSnapshotsCannotContainFacts() throws { + for outcome in [CollectionOutcome.failed, .notCollected, .unknown] { + XCTAssertThrowsError(try ProducerSnapshot(producer: .scree, runID: "run", observedAt: nil, + outcome: outcome, coverage: coverage, records: [record()])) + let empty = try ProducerSnapshot(producer: .scree, runID: "run", observedAt: nil, + outcome: outcome, coverage: coverage, records: []) + XCTAssertTrue(empty.records.isEmpty) + } + } + + func testPartialSnapshotRetainsFactsAndSeparateFailures() throws { + let snapshot = try ProducerSnapshot(producer: .scree, runID: "run", observedAt: nil, + outcome: .partial, coverage: coverage, records: [record()], + failures: [ProducerFailure(code: .unreadable, recordID: "other", detail: "Cannot read input")]) + XCTAssertEqual(snapshot.records.count, 1) + XCTAssertEqual(snapshot.failures.count, 1) + XCTAssertNil(snapshot.records[0].evidence.observedAt) + XCTAssertEqual(try JSONDecoder().decode(ProducerSnapshot.self, + from: JSONEncoder().encode(snapshot)), snapshot) + } + + func testSnapshotRejectsWrongRunSourceDuplicateIDsAndUnsupportedSchema() throws { + let observation = try record() + XCTAssertThrowsError(try ProducerSnapshot(producer: .scree, runID: "other", observedAt: nil, + outcome: .complete, coverage: coverage, records: [observation])) + XCTAssertThrowsError(try ProducerSnapshot(producer: .projectAttribution, runID: "run", observedAt: nil, + outcome: .complete, coverage: coverage, records: [observation])) + XCTAssertThrowsError(try ProducerSnapshot(producer: .scree, runID: "run", observedAt: nil, + outcome: .complete, coverage: coverage, records: [observation, observation])) + let snapshot = try ProducerSnapshot(producer: .scree, runID: "run", observedAt: nil, + outcome: .complete, coverage: coverage, records: [observation]) + let encoded = try JSONEncoder().encode(snapshot) + let original = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + for (key, value) in [("runID", "other" as Any), ("producer", "projectAttribution"), + ("outcome", "failed"), ("schemaVersion", 99)] { + var json = original + json[key] = value + XCTAssertThrowsError(try JSONDecoder().decode(ProducerSnapshot.self, + from: JSONSerialization.data(withJSONObject: json)), key) + } + } +}