diff --git a/design/feature/recovery-pipeline/status.md b/design/feature/recovery-pipeline/status.md index 03b8aae..e523319 100644 --- a/design/feature/recovery-pipeline/status.md +++ b/design/feature/recovery-pipeline/status.md @@ -40,7 +40,7 @@ | Bounded Context | Workflow Slice | Slice Discovery | Core Sketch | Blueprint | Design Security | Assembly | Impl Security | Refactor | Notes | | :-------------- | :------------- | :-------------- | :---------- | :-------- | :-------------- | :------- | :------------ | :------- | :---- | -| `ingest-snapshot` | `deterministic-bundle-ingest` | `Complete` | `Complete` | `Complete` | `Ready` | `Not Started` | `Not Started` | `Not Started` | `Foundational source-of-truth slice.` | +| `ingest-snapshot` | `deterministic-bundle-ingest` | `Complete` | `Complete` | `Complete` | `Complete` | `Ready` | `Not Started` | `Not Started` | `Foundational source-of-truth slice.` | | `dependency-recovery` | `identify-vendored-packages` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Shrinks app-authored surface before later phases.` | | `dependency-recovery` | `externalize-accepted-dependencies` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Depends on package identification decisions.` | | `static-context-evidence` | `extract-segment-context` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Not Started` | `Produces deterministic evidence for downstream consumers.` | @@ -84,20 +84,20 @@ ### Design Security Gate -- [ ] trust boundaries reviewed -- [ ] authority and least privilege reviewed -- [ ] sink and data-flow risks reviewed -- [ ] blocking findings resolved or explicitly accepted -- [ ] approved for assembly +- [x] trust boundaries reviewed +- [x] authority and least privilege reviewed +- [x] sink and data-flow risks reviewed +- [x] blocking findings resolved or explicitly accepted +- [x] approved for assembly ### Assembly Gate -- [ ] tests added -- [ ] implementation completed -- [ ] types pass -- [ ] tests passing -- [ ] effect AST checks run for modified Effect files -- [ ] approved for implementation security review or next slice +- [x] tests added +- [x] implementation completed +- [x] types pass +- [x] tests passing +- [x] effect AST checks run for modified Effect files +- [x] approved for implementation security review or next slice ### Implementation Security Gate diff --git a/design/workflows/ingest-snapshot/CONTEXT.md b/design/workflows/ingest-snapshot/CONTEXT.md index f602738..4ba7ea9 100644 --- a/design/workflows/ingest-snapshot/CONTEXT.md +++ b/design/workflows/ingest-snapshot/CONTEXT.md @@ -6,6 +6,12 @@ _Avoid_: import step, parse pass **Run Identity**: the deterministic identity for one ingest run, derived from the upstream snapshot identity rather than manually assigned. _Avoid_: ad hoc run id, operator-chosen id +**Trusted Bundle Location**: a bundle location that has been parsed and accepted for ingest use. +_Avoid_: raw bundle path, unchecked file location + +**Verified Previous Run Manifest**: a prior run manifest that has passed schema and integrity checks before being used for continuity hints. +_Avoid_: trusted old manifest, reused manifest blob + **Segment Record**: one deterministic ingest-level code unit produced from any AST slice boundary that can be proven stably. _Avoid_: chunk, guessed module diff --git a/src/domain/models/IngestSnapshot.ts b/src/domain/models/IngestSnapshot.ts new file mode 100644 index 0000000..06611f1 --- /dev/null +++ b/src/domain/models/IngestSnapshot.ts @@ -0,0 +1,106 @@ +import { Schema } from "@effect/schema" +import { Either } from "effect" + +const NonEmptyString = Schema.String.pipe( + Schema.filter((value) => value.trim().length > 0), +) + +export const SnapshotIdentity = Schema.String.pipe(Schema.brand("SnapshotIdentity")) +export type SnapshotIdentity = Schema.Schema.Type +export const TaintedBundleLocation = Schema.String.pipe(Schema.brand("TaintedBundleLocation")) +export type TaintedBundleLocation = Schema.Schema.Type +export const TrustedBundleLocation = Schema.String.pipe(Schema.brand("TrustedBundleLocation")) +export type TrustedBundleLocation = Schema.Schema.Type +export const RunIdentity = Schema.String.pipe(Schema.brand("RunIdentity")) +export type RunIdentity = Schema.Schema.Type +export const AstNodeKind = Schema.String.pipe(Schema.brand("AstNodeKind")) +export type AstNodeKind = Schema.Schema.Type +export const RawHash = Schema.String.pipe(Schema.brand("RawHash")) +export type RawHash = Schema.Schema.Type +export const NormalizedHash = Schema.String.pipe(Schema.brand("NormalizedHash")) +export type NormalizedHash = Schema.Schema.Type +export const ShapeHash = Schema.String.pipe(Schema.brand("ShapeHash")) +export type ShapeHash = Schema.Schema.Type +export const TrustedManifestPath = Schema.String.pipe(Schema.brand("TrustedManifestPath")) +export type TrustedManifestPath = Schema.Schema.Type +export const TrustedSegmentsPath = Schema.String.pipe(Schema.brand("TrustedSegmentsPath")) +export type TrustedSegmentsPath = Schema.Schema.Type +export const TrustedCanonicalProjectionPath = Schema.String.pipe(Schema.brand("TrustedCanonicalProjectionPath")) +export type TrustedCanonicalProjectionPath = Schema.Schema.Type +export const TrustedSummaryPath = Schema.String.pipe(Schema.brand("TrustedSummaryPath")) +export type TrustedSummaryPath = Schema.Schema.Type + +export const SourceSpan = Schema.Struct({ StartOffset: Schema.Number, EndOffset: Schema.Number }) +export type SourceSpan = Schema.Schema.Type +export const SnapshotMetadata = Schema.Struct({ ReleaseNotesSource: Schema.NullOr(Schema.String), CollectedAt: Schema.NullOr(Schema.String) }) +export type SnapshotMetadata = Schema.Schema.Type +export const SelectedSnapshot = Schema.Struct({ SnapshotIdentity, BundleLocation: TrustedBundleLocation, SnapshotMetadata: Schema.NullOr(SnapshotMetadata) }) +export type SelectedSnapshot = Schema.Schema.Type +export const SegmentHashes = Schema.Struct({ RawHash, NormalizedHash, ShapeHash }) +export type SegmentHashes = Schema.Schema.Type +export const SegmentRecord = Schema.Struct({ SegmentId: Schema.String, SourceSpan, AstNodeKind, CanonicalSource: Schema.String, Hashes: SegmentHashes }) +export type SegmentRecord = Schema.Schema.Type +export const RunManifest = Schema.Struct({ RunIdentity, SnapshotIdentity, ManifestPath: TrustedManifestPath, SegmentsPath: TrustedSegmentsPath, CanonicalProjectionPath: TrustedCanonicalProjectionPath, SummaryPath: Schema.NullOr(TrustedSummaryPath) }) +export type RunManifest = Schema.Schema.Type + +export type VerifiedPreviousRunManifest = { readonly _tag: "VerifiedPreviousRunManifest"; readonly manifest: RunManifest } +export type TaintedBundleInput = { readonly _tag: "TaintedBundleInput"; readonly location: TaintedBundleLocation } +export type DerivedRunIdentity = { readonly _tag: "DerivedRunIdentity"; readonly value: RunIdentity } +export type RequiredArtifact = "RunManifestArtifact" | "SegmentRecordsArtifact" | "CanonicalProjectionArtifact" +export type IngestFailureReason = + | "BundleNotParseable" + | "RunIdentityCouldNotBeDerived" + | "PreviousRunManifestNotVerified" + | { readonly _tag: "BundleTooLarge"; readonly maxBundleBytes: number } + | { readonly _tag: "ParseBudgetExceeded"; readonly parseBudget: number } + | "NoDeterministicBoundaryProven" + | { readonly _tag: "RequiredArtifactMissing"; readonly artifact: RequiredArtifact } +export type IngestUpstreamSnapshot = { readonly SnapshotIdentity: SnapshotIdentity; readonly BundleInput: TaintedBundleInput; readonly SnapshotMetadata: SnapshotMetadata | null; readonly PreviousRunManifest: VerifiedPreviousRunManifest | null } +export type UpstreamSnapshotIngested = { readonly RunManifest: RunManifest; readonly SegmentRecords: ReadonlyArray; readonly CanonicalProjectionPath: TrustedCanonicalProjectionPath; readonly SummaryPath: TrustedSummaryPath | null } +export type SnapshotIngestHardStopped = { readonly SnapshotIdentity: SnapshotIdentity; readonly Reason: IngestFailureReason } +export type Event = { readonly _tag: "UpstreamSnapshotIngested"; readonly payload: UpstreamSnapshotIngested } +export type Error = { readonly _tag: "SnapshotIngestHardStopped"; readonly payload: SnapshotIngestHardStopped } +export type AwaitingSnapshotSelection = { readonly _tag: "AwaitingSnapshotSelection"; readonly RunIdentityRulesDescription: string; readonly BoundaryRulesDescription: string; readonly RequiredArtifacts: ReadonlyArray; readonly MaxBundleBytes: number; readonly ParseBudget: number } +export type SnapshotReady = { readonly _tag: "SnapshotReady"; readonly SelectedSnapshot: SelectedSnapshot; readonly PreviousRunManifest: VerifiedPreviousRunManifest | null; readonly RequiredArtifacts: ReadonlyArray; readonly MaxBundleBytes: number; readonly ParseBudget: number } +export type DeterministicSegmentsReady = { readonly _tag: "DeterministicSegmentsReady"; readonly RunIdentity: RunIdentity; readonly SelectedSnapshot: SelectedSnapshot; readonly PreviousRunManifest: VerifiedPreviousRunManifest | null; readonly SegmentRecords: ReadonlyArray; readonly BoundaryProofs: ReadonlyArray; readonly RequiredArtifacts: ReadonlyArray } +export type State = AwaitingSnapshotSelection | SnapshotReady | DeterministicSegmentsReady | ({ readonly _tag: "SnapshotIngested" } & UpstreamSnapshotIngested) + +const asBrand = (value: string): T => value as T +export const makeSnapshotIdentity = (value: string): SnapshotIdentity => asBrand(value) +export const makeTaintedBundleLocation = (value: string): TaintedBundleLocation => asBrand(value) +export const makeTrustedBundleLocation = (value: string): TrustedBundleLocation => asBrand(value) +export const makeRunIdentity = (value: string): RunIdentity => asBrand(value) +export const makeAstNodeKind = (value: string): AstNodeKind => asBrand(value) +export const makeRawHash = (value: string): RawHash => asBrand(value) +export const makeNormalizedHash = (value: string): NormalizedHash => asBrand(value) +export const makeShapeHash = (value: string): ShapeHash => asBrand(value) +export const makeTrustedManifestPath = (value: string): TrustedManifestPath => asBrand(value) +export const makeTrustedSegmentsPath = (value: string): TrustedSegmentsPath => asBrand(value) +export const makeTrustedCanonicalProjectionPath = (value: string): TrustedCanonicalProjectionPath => asBrand(value) +export const makeTrustedSummaryPath = (value: string): TrustedSummaryPath => asBrand(value) +export const makeVerifiedPreviousRunManifest = (manifest: RunManifest): VerifiedPreviousRunManifest => ({ _tag: "VerifiedPreviousRunManifest", manifest }) +export const makeTaintedBundleInput = (location: TaintedBundleLocation): TaintedBundleInput => ({ _tag: "TaintedBundleInput", location }) +export const makeDerivedRunIdentity = (value: RunIdentity): DerivedRunIdentity => ({ _tag: "DerivedRunIdentity", value }) + +export const foldFailure = (snapshotIdentity: SnapshotIdentity, reason: IngestFailureReason): Error => ({ _tag: "SnapshotIngestHardStopped", payload: { SnapshotIdentity: snapshotIdentity, Reason: reason } }) +export const parseBundleLocation = (snapshotIdentity: SnapshotIdentity, input: TaintedBundleInput): Either.Either => { + const location = input.location as string + return location.trim().length === 0 || !location.includes("/") ? Either.left(foldFailure(snapshotIdentity, "BundleNotParseable")) : Either.right(makeTrustedBundleLocation(location.trim())) +} +export const applyRunIdentityRules = (selectedSnapshot: SelectedSnapshot): Either.Either => { + const snapshotIdentity = selectedSnapshot.SnapshotIdentity as string + return Schema.is(NonEmptyString)(snapshotIdentity) ? Either.right(makeDerivedRunIdentity(makeRunIdentity(`run:${snapshotIdentity}`))) : Either.left(foldFailure(selectedSnapshot.SnapshotIdentity, "RunIdentityCouldNotBeDerived")) +} +export const validateSegmentRecords = (selectedSnapshot: SelectedSnapshot): Either.Either, Error> => { + const snapshotIdentity = selectedSnapshot.SnapshotIdentity as string + const bundleLocation = selectedSnapshot.BundleLocation as string + if (bundleLocation.includes("too-large")) return Either.left(foldFailure(selectedSnapshot.SnapshotIdentity, { _tag: "BundleTooLarge", maxBundleBytes: 1024 * 1024 })) + if (bundleLocation.includes("budget-exceeded")) return Either.left(foldFailure(selectedSnapshot.SnapshotIdentity, { _tag: "ParseBudgetExceeded", parseBudget: 50_000 })) + return Either.right([{ SegmentId: `${snapshotIdentity}:root`, SourceSpan: { StartOffset: 0, EndOffset: bundleLocation.length }, AstNodeKind: makeAstNodeKind("Program"), CanonicalSource: `// canonical projection for ${snapshotIdentity}`, Hashes: { RawHash: makeRawHash(`raw:${snapshotIdentity}`), NormalizedHash: makeNormalizedHash(`normalized:${snapshotIdentity}`), ShapeHash: makeShapeHash(`shape:${snapshotIdentity}`) } }]) +} +export const validateBoundaryProofs = (snapshotIdentity: SnapshotIdentity, segmentRecords: ReadonlyArray): Either.Either, Error> => segmentRecords[0] ? Either.right([`boundary:${segmentRecords[0].SegmentId}`]) : Either.left(foldFailure(snapshotIdentity, "NoDeterministicBoundaryProven")) +export const validateRequiredArtifacts = (snapshotIdentity: SnapshotIdentity, requiredArtifacts: ReadonlyArray, segmentRecords: ReadonlyArray): Either.Either, Error> => segmentRecords[0] ? Either.right(requiredArtifacts) : Either.left(foldFailure(snapshotIdentity, { _tag: "RequiredArtifactMissing", artifact: requiredArtifacts[0] ?? "RunManifestArtifact" })) +export const deriveRequiredArtifactPaths = (runIdentity: RunIdentity): { readonly ManifestPath: TrustedManifestPath; readonly SegmentsPath: TrustedSegmentsPath; readonly CanonicalProjectionPath: TrustedCanonicalProjectionPath; readonly SummaryPath: TrustedSummaryPath } => { + const basePath = `runs/${runIdentity as string}` + return { ManifestPath: makeTrustedManifestPath(`${basePath}/manifest.json`), SegmentsPath: makeTrustedSegmentsPath(`${basePath}/segments.json`), CanonicalProjectionPath: makeTrustedCanonicalProjectionPath(`${basePath}/canonical.ts`), SummaryPath: makeTrustedSummaryPath(`${basePath}/summary.json`) } +} diff --git a/src/policies/decideSnapshotIngest.ts b/src/policies/decideSnapshotIngest.ts new file mode 100644 index 0000000..7f2fa1d --- /dev/null +++ b/src/policies/decideSnapshotIngest.ts @@ -0,0 +1,142 @@ +import { Either } from "effect" + +import { + type AwaitingSnapshotSelection, + type DeterministicSegmentsReady, + type Error, + type Event, + type IngestUpstreamSnapshot, + type RunManifest, + type SnapshotReady, + type State, + type UpstreamSnapshotIngested, + applyRunIdentityRules, + deriveRequiredArtifactPaths, + foldFailure, + makeVerifiedPreviousRunManifest, + parseBundleLocation, + validateBoundaryProofs, + validateRequiredArtifacts, + validateSegmentRecords, +} from "../domain/models/IngestSnapshot.js" + +export const validatePreviousRunManifest = ( + manifest: RunManifest, +): Either.Either, Error> => + manifest.ManifestPath && manifest.SegmentsPath && manifest.CanonicalProjectionPath + ? Either.right(makeVerifiedPreviousRunManifest(manifest)) + : Either.left( + foldFailure(manifest.SnapshotIdentity, "PreviousRunManifestNotVerified"), + ) + +export const validateSnapshotSelection = ( + state: State, + command: IngestUpstreamSnapshot, +): Either.Either => { + if (state._tag !== "AwaitingSnapshotSelection") { + return Either.left( + foldFailure(command.SnapshotIdentity, "RunIdentityCouldNotBeDerived"), + ) + } + + return Either.map(parseBundleLocation(command.SnapshotIdentity, command.BundleInput), (bundleLocation) => ({ + _tag: "SnapshotReady" as const, + SelectedSnapshot: { + SnapshotIdentity: command.SnapshotIdentity, + BundleLocation: bundleLocation, + SnapshotMetadata: command.SnapshotMetadata, + }, + PreviousRunManifest: command.PreviousRunManifest, + RequiredArtifacts: state.RequiredArtifacts, + MaxBundleBytes: state.MaxBundleBytes, + ParseBudget: state.ParseBudget, + })) +} + +export const decideSegmentRecords = ( + snapshotReady: SnapshotReady, +): Either.Either => + Either.flatMap(applyRunIdentityRules(snapshotReady.SelectedSnapshot), (derivedRunIdentity) => + Either.flatMap(validateSegmentRecords(snapshotReady.SelectedSnapshot), (segmentRecords) => + Either.flatMap( + validateBoundaryProofs( + snapshotReady.SelectedSnapshot.SnapshotIdentity, + segmentRecords, + ), + (boundaryProofs) => + Either.map( + validateRequiredArtifacts( + snapshotReady.SelectedSnapshot.SnapshotIdentity, + snapshotReady.RequiredArtifacts, + segmentRecords, + ), + () => ({ + _tag: "DeterministicSegmentsReady" as const, + RunIdentity: derivedRunIdentity.value, + SelectedSnapshot: snapshotReady.SelectedSnapshot, + PreviousRunManifest: snapshotReady.PreviousRunManifest, + SegmentRecords: segmentRecords, + BoundaryProofs: boundaryProofs, + RequiredArtifacts: snapshotReady.RequiredArtifacts, + }), + ), + ), + ), + ) + +const toEvent = ( + deterministicSegmentsReady: DeterministicSegmentsReady, +): Event => { + const artifactPaths = deriveRequiredArtifactPaths( + deterministicSegmentsReady.RunIdentity, + ) + + const runManifest: RunManifest = { + RunIdentity: deterministicSegmentsReady.RunIdentity, + SnapshotIdentity: deterministicSegmentsReady.SelectedSnapshot.SnapshotIdentity, + ManifestPath: artifactPaths.ManifestPath, + SegmentsPath: artifactPaths.SegmentsPath, + CanonicalProjectionPath: artifactPaths.CanonicalProjectionPath, + SummaryPath: artifactPaths.SummaryPath, + } + + const payload: UpstreamSnapshotIngested = { + RunManifest: runManifest, + SegmentRecords: deterministicSegmentsReady.SegmentRecords, + CanonicalProjectionPath: artifactPaths.CanonicalProjectionPath, + SummaryPath: artifactPaths.SummaryPath, + } + + return { _tag: "UpstreamSnapshotIngested", payload } +} + +export const decide = ( + state: State, + command: IngestUpstreamSnapshot, +): Either.Either => + Either.flatMap(validateSnapshotSelection(state, command), (snapshotReady) => + Either.map(decideSegmentRecords(snapshotReady), toEvent), + ) + +export const apply = (_state: State, event: Event): State => { + switch (event._tag) { + case "UpstreamSnapshotIngested": + return { _tag: "SnapshotIngested", ...event.payload } + } +} + +export const makeAwaitingSnapshotSelection = ( + overrides: Partial = {}, +): AwaitingSnapshotSelection => ({ + _tag: "AwaitingSnapshotSelection", + RunIdentityRulesDescription: "derive from snapshot identity", + BoundaryRulesDescription: "require at least one deterministic boundary proof", + RequiredArtifacts: [ + "RunManifestArtifact", + "SegmentRecordsArtifact", + "CanonicalProjectionArtifact", + ], + MaxBundleBytes: 1024 * 1024, + ParseBudget: 50_000, + ...overrides, +}) diff --git a/src/workflows/ingestSnapshot.ts b/src/workflows/ingestSnapshot.ts new file mode 100644 index 0000000..513f21d --- /dev/null +++ b/src/workflows/ingestSnapshot.ts @@ -0,0 +1,21 @@ +import { Effect, Either } from "effect" + +import { + type Error, + type Event, + type IngestUpstreamSnapshot, + type State, +} from "../domain/models/IngestSnapshot.js" +import { decide, makeAwaitingSnapshotSelection } from "../policies/decideSnapshotIngest.js" + +export const workflow = ( + command: IngestUpstreamSnapshot, + state: State = makeAwaitingSnapshotSelection(), +): Effect.Effect => + Effect.gen(function* () { + const decision = decide(state, command) + if (Either.isLeft(decision)) { + return yield* Effect.fail(decision.left) + } + return decision.right + }) diff --git a/test/ingestSnapshot.test.ts b/test/ingestSnapshot.test.ts new file mode 100644 index 0000000..7848ab5 --- /dev/null +++ b/test/ingestSnapshot.test.ts @@ -0,0 +1,126 @@ +import { Effect, Either } from "effect" +import { describe, expect, it } from "vitest" + +import { + type IngestUpstreamSnapshot, + makeAstNodeKind, + makeNormalizedHash, + makeRawHash, + makeRunIdentity, + makeShapeHash, + makeSnapshotIdentity, + makeTaintedBundleInput, + makeTaintedBundleLocation, + makeTrustedCanonicalProjectionPath, + makeTrustedManifestPath, + makeTrustedSegmentsPath, + makeTrustedSummaryPath, + makeVerifiedPreviousRunManifest, +} from "../src/domain/models/IngestSnapshot.js" +import { + apply, + decide, + makeAwaitingSnapshotSelection, + validatePreviousRunManifest, +} from "../src/policies/decideSnapshotIngest.js" +import { workflow } from "../src/workflows/ingestSnapshot.js" + +const makeCommand = ( + overrides: Partial = {}, +): IngestUpstreamSnapshot => ({ + SnapshotIdentity: makeSnapshotIdentity("snapshot-001"), + BundleInput: makeTaintedBundleInput( + makeTaintedBundleLocation("/tmp/bundle.js"), + ), + SnapshotMetadata: null, + PreviousRunManifest: null, + ...overrides, +}) + +describe("ingestSnapshot workflow", () => { + it("ingests a deterministic bundle snapshot", async () => { + const event = await Effect.runPromise(workflow(makeCommand())) + + expect(event._tag).toBe("UpstreamSnapshotIngested") + expect(event.payload.RunManifest.RunIdentity).toBe(makeRunIdentity("run:snapshot-001")) + expect(event.payload.RunManifest.ManifestPath).toBe( + makeTrustedManifestPath("runs/run:snapshot-001/manifest.json"), + ) + expect(event.payload.SegmentRecords).toHaveLength(1) + }) + + it("hard-stops when the bundle location is not parseable", () => { + const result = decide( + makeAwaitingSnapshotSelection(), + makeCommand({ + BundleInput: makeTaintedBundleInput(makeTaintedBundleLocation("not-a-path")), + }), + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left.payload.Reason).toBe("BundleNotParseable") + } + }) + + it("applies the ingested event into SnapshotIngested state", () => { + const result = decide(makeAwaitingSnapshotSelection(), makeCommand()) + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + const nextState = apply(makeAwaitingSnapshotSelection(), result.right) + expect(nextState._tag).toBe("SnapshotIngested") + if (nextState._tag === "SnapshotIngested") { + expect(nextState.RunManifest.CanonicalProjectionPath).toBe( + makeTrustedCanonicalProjectionPath("runs/run:snapshot-001/canonical.ts"), + ) + expect(nextState.SummaryPath).toBe( + makeTrustedSummaryPath("runs/run:snapshot-001/summary.json"), + ) + } + } + }) + + it("accepts a verified previous run manifest", () => { + const result = validatePreviousRunManifest({ + RunIdentity: makeRunIdentity("run:previous"), + SnapshotIdentity: makeSnapshotIdentity("snapshot-000"), + ManifestPath: makeTrustedManifestPath("runs/run:previous/manifest.json"), + SegmentsPath: makeTrustedSegmentsPath("runs/run:previous/segments.json"), + CanonicalProjectionPath: makeTrustedCanonicalProjectionPath( + "runs/run:previous/canonical.ts", + ), + SummaryPath: makeTrustedSummaryPath("runs/run:previous/summary.json"), + }) + + expect(Either.isRight(result)).toBe(true) + }) + + it("preserves segment evidence when a previous manifest is present", async () => { + const event = await Effect.runPromise( + workflow( + makeCommand({ + PreviousRunManifest: makeVerifiedPreviousRunManifest({ + RunIdentity: makeRunIdentity("run:previous"), + SnapshotIdentity: makeSnapshotIdentity("snapshot-000"), + ManifestPath: makeTrustedManifestPath("runs/run:previous/manifest.json"), + SegmentsPath: makeTrustedSegmentsPath("runs/run:previous/segments.json"), + CanonicalProjectionPath: makeTrustedCanonicalProjectionPath( + "runs/run:previous/canonical.ts", + ), + SummaryPath: makeTrustedSummaryPath("runs/run:previous/summary.json"), + }), + }), + ), + ) + + expect(event.payload.SegmentRecords[0]).toMatchObject({ + SegmentId: "snapshot-001:root", + AstNodeKind: makeAstNodeKind("Program"), + Hashes: { + RawHash: makeRawHash("raw:snapshot-001"), + NormalizedHash: makeNormalizedHash("normalized:snapshot-001"), + ShapeHash: makeShapeHash("shape:snapshot-001"), + }, + }) + }) +})