diff --git a/design/feature/recovery-pipeline/status.md b/design/feature/recovery-pipeline/status.md index e523319..d545fef 100644 --- a/design/feature/recovery-pipeline/status.md +++ b/design/feature/recovery-pipeline/status.md @@ -4,8 +4,8 @@ - Name: `Recovery Pipeline` - Feature slug: `recovery-pipeline` -- Current phase: `Context & Workflow Decomposition` -- Overall status: `Decomposition In Progress` +- Current phase: `Implementation Security Review` +- Overall status: `Assembly Complete` - Security verification status: `Not Started` - Current workflow slice: `ingest-snapshot/deterministic-bundle-ingest` @@ -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` | `Complete` | `Ready` | `Not Started` | `Not Started` | `Foundational source-of-truth slice.` | +| `ingest-snapshot` | `deterministic-bundle-ingest` | `Complete` | `Complete` | `Complete` | `Complete` | `Complete` | `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.` | diff --git a/src/contexts/ingest-snapshot/index.ts b/src/contexts/ingest-snapshot/index.ts index a3095a8..e6943f8 100644 --- a/src/contexts/ingest-snapshot/index.ts +++ b/src/contexts/ingest-snapshot/index.ts @@ -4,4 +4,10 @@ export * from "./models/factories.js" export * from "./models/ops.js" export * from "./policies/selection.js" export * from "./policies/segments.js" -export * from "./workflows/ingestSnapshot.js" + +export { workflow } from "./workflows/ingestSnapshot.js" +export { + apply, + decide, + makeAwaitingSnapshotSelection, +} from "./policies/decideSnapshotIngest.js" diff --git a/src/contexts/ingest-snapshot/models/factories.ts b/src/contexts/ingest-snapshot/models/factories.ts index 2b76c89..bfb50ba 100644 --- a/src/contexts/ingest-snapshot/models/factories.ts +++ b/src/contexts/ingest-snapshot/models/factories.ts @@ -1 +1,64 @@ -export * from "../../../domain/models/ingestSnapshot/factories.js" +import type { + DerivedRunIdentity, + Error, + IngestFailureReason, + TaintedBundleInput, + VerifiedPreviousRunManifest, +} from "./types.js" +import type { + AstNodeKind, + NormalizedHash, + RawHash, + RunIdentity, + RunManifest, + ShapeHash, + SnapshotIdentity, + TaintedBundleLocation, + TrustedBundleLocation, + TrustedCanonicalProjectionPath, + TrustedManifestPath, + TrustedSegmentsPath, + TrustedSummaryPath, +} from "./shared.js" + +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 }, +}) diff --git a/src/contexts/ingest-snapshot/models/ops.ts b/src/contexts/ingest-snapshot/models/ops.ts index f355a5a..10f4bbe 100644 --- a/src/contexts/ingest-snapshot/models/ops.ts +++ b/src/contexts/ingest-snapshot/models/ops.ts @@ -1 +1,156 @@ -export * from "../../../domain/models/ingestSnapshot/ops.js" +import { Either } from "effect" + +import { + isNonEmptyString, + type RunIdentity, + type SegmentRecord, + type SelectedSnapshot, + type SnapshotIdentity, + type TrustedCanonicalProjectionPath, + type TrustedManifestPath, + type TrustedSegmentsPath, + type TrustedSummaryPath, +} from "./shared.js" +import { + foldFailure, + makeAstNodeKind, + makeDerivedRunIdentity, + makeNormalizedHash, + makeRawHash, + makeRunIdentity, + makeShapeHash, + makeTrustedBundleLocation, + makeTrustedCanonicalProjectionPath, + makeTrustedManifestPath, + makeTrustedSegmentsPath, + makeTrustedSummaryPath, +} from "./factories.js" +import type { + DerivedRunIdentity, + Error, + RequiredArtifact, + TaintedBundleInput, +} from "./types.js" + +const parseBundleLocationText = (location: string): string | null => { + const trimmedLocation = location.trim() + return trimmedLocation.length === 0 || !trimmedLocation.includes("/") + ? null + : trimmedLocation +} + +const decideSegmentRecordFailure = ( + selectedSnapshot: SelectedSnapshot, + bundleLocation: string, +): Error | null => { + if (bundleLocation.includes("too-large")) { + return foldFailure(selectedSnapshot.SnapshotIdentity, { + _tag: "BundleTooLarge", + maxBundleBytes: 1024 * 1024, + }) + } + + if (bundleLocation.includes("budget-exceeded")) { + return foldFailure(selectedSnapshot.SnapshotIdentity, { + _tag: "ParseBudgetExceeded", + parseBudget: 50_000, + }) + } + + return null +} + +export const parseBundleLocation = ( + snapshotIdentity: SnapshotIdentity, + input: TaintedBundleInput, +): Either.Either, Error> => { + const location = parseBundleLocationText(input.location as string) + return location === null + ? Either.left(foldFailure(snapshotIdentity, "BundleNotParseable")) + : Either.right(makeTrustedBundleLocation(location)) +} + +export const applyRunIdentityRules = ( + selectedSnapshot: SelectedSnapshot, +): Either.Either => { + const snapshotIdentity = selectedSnapshot.SnapshotIdentity as string + return isNonEmptyString(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 + const failure = decideSegmentRecordFailure(selectedSnapshot, bundleLocation) + + if (failure) { + return Either.left(failure) + } + + 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> => { + const firstSegment = segmentRecords[0] + return firstSegment + ? Either.right([`boundary:${firstSegment.SegmentId}`]) + : Either.left(foldFailure(snapshotIdentity, "NoDeterministicBoundaryProven")) +} + +export const validateRequiredArtifacts = ( + snapshotIdentity: SnapshotIdentity, + requiredArtifacts: ReadonlyArray, + segmentRecords: ReadonlyArray, +): Either.Either, Error> => { + const missingArtifact = segmentRecords[0] ? null : requiredArtifacts[0] + return missingArtifact === null + ? Either.right(requiredArtifacts) + : Either.left( + foldFailure(snapshotIdentity, { + _tag: "RequiredArtifactMissing", + artifact: missingArtifact ?? "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/contexts/ingest-snapshot/models/shared.ts b/src/contexts/ingest-snapshot/models/shared.ts index da33721..ea18397 100644 --- a/src/contexts/ingest-snapshot/models/shared.ts +++ b/src/contexts/ingest-snapshot/models/shared.ts @@ -1 +1,102 @@ -export * from "../../../domain/models/ingestSnapshot/shared.js" +import { Schema } from "@effect/schema" + +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 const isNonEmptyString = (value: string): boolean => + Schema.is(NonEmptyString)(value) diff --git a/src/contexts/ingest-snapshot/models/types.ts b/src/contexts/ingest-snapshot/models/types.ts index da73c00..86dbbc9 100644 --- a/src/contexts/ingest-snapshot/models/types.ts +++ b/src/contexts/ingest-snapshot/models/types.ts @@ -1 +1,103 @@ -export * from "../../../domain/models/ingestSnapshot/types.js" +import type { + RunIdentity, + RunManifest, + SegmentRecord, + SelectedSnapshot, + SnapshotIdentity, + SnapshotMetadata, + TrustedCanonicalProjectionPath, + TrustedSummaryPath, + TaintedBundleLocation, +} from "./shared.js" + +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) diff --git a/src/policies/decideSnapshotIngest.ts b/src/contexts/ingest-snapshot/policies/decideSnapshotIngest.ts similarity index 93% rename from src/policies/decideSnapshotIngest.ts rename to src/contexts/ingest-snapshot/policies/decideSnapshotIngest.ts index 68536c5..059e9d9 100644 --- a/src/policies/decideSnapshotIngest.ts +++ b/src/contexts/ingest-snapshot/policies/decideSnapshotIngest.ts @@ -9,14 +9,12 @@ import { type State, type UpstreamSnapshotIngested, deriveRequiredArtifactPaths, -} from "../domain/models/IngestSnapshot.js" -import { - decideSegmentRecords, -} from "./ingestSnapshot/segments.js" +} from "../index.js" +import { decideSegmentRecords } from "./segments.js" import { validatePreviousRunManifest, validateSnapshotSelection, -} from "./ingestSnapshot/selection.js" +} from "./selection.js" const toEvent = ( deterministicSegmentsReady: DeterministicSegmentsReady, diff --git a/src/contexts/ingest-snapshot/policies/segments.ts b/src/contexts/ingest-snapshot/policies/segments.ts index d901a9e..6c42600 100644 --- a/src/contexts/ingest-snapshot/policies/segments.ts +++ b/src/contexts/ingest-snapshot/policies/segments.ts @@ -1 +1,42 @@ -export * from "../../../policies/ingestSnapshot/segments.js" +import { Either } from "effect" + +import { + type DeterministicSegmentsReady, + type Error, + type SnapshotReady, + applyRunIdentityRules, + validateBoundaryProofs, + validateRequiredArtifacts, + validateSegmentRecords, +} from "../index.js" + +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, + }), + ), + ), + ), + ) diff --git a/src/contexts/ingest-snapshot/policies/selection.ts b/src/contexts/ingest-snapshot/policies/selection.ts index a58cca1..c2ee652 100644 --- a/src/contexts/ingest-snapshot/policies/selection.ts +++ b/src/contexts/ingest-snapshot/policies/selection.ts @@ -1 +1,48 @@ -export * from "../../../policies/ingestSnapshot/selection.js" +import { Either } from "effect" + +import { + type Error, + type IngestUpstreamSnapshot, + type RunManifest, + type SnapshotReady, + type State, + foldFailure, + makeVerifiedPreviousRunManifest, + parseBundleLocation, +} from "../index.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, + }), + ) +} diff --git a/src/contexts/ingest-snapshot/workflows/ingestSnapshot.ts b/src/contexts/ingest-snapshot/workflows/ingestSnapshot.ts index 32469a0..b93e53a 100644 --- a/src/contexts/ingest-snapshot/workflows/ingestSnapshot.ts +++ b/src/contexts/ingest-snapshot/workflows/ingestSnapshot.ts @@ -1 +1,22 @@ -export * from "../../../workflows/ingestSnapshot.js" +import { Effect, Either } from "effect" + +import { + type Error, + type Event, + type IngestUpstreamSnapshot, + type State, + decide, + makeAwaitingSnapshotSelection, +} from "../index.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/src/domain/models/IngestSnapshot.ts b/src/domain/models/IngestSnapshot.ts deleted file mode 100644 index d38ce32..0000000 --- a/src/domain/models/IngestSnapshot.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./ingestSnapshot/shared.js" -export * from "./ingestSnapshot/types.js" -export * from "./ingestSnapshot/factories.js" -export * from "./ingestSnapshot/ops.js" diff --git a/src/domain/models/ingestSnapshot/factories.ts b/src/domain/models/ingestSnapshot/factories.ts deleted file mode 100644 index bfb50ba..0000000 --- a/src/domain/models/ingestSnapshot/factories.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { - DerivedRunIdentity, - Error, - IngestFailureReason, - TaintedBundleInput, - VerifiedPreviousRunManifest, -} from "./types.js" -import type { - AstNodeKind, - NormalizedHash, - RawHash, - RunIdentity, - RunManifest, - ShapeHash, - SnapshotIdentity, - TaintedBundleLocation, - TrustedBundleLocation, - TrustedCanonicalProjectionPath, - TrustedManifestPath, - TrustedSegmentsPath, - TrustedSummaryPath, -} from "./shared.js" - -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 }, -}) diff --git a/src/domain/models/ingestSnapshot/ops.ts b/src/domain/models/ingestSnapshot/ops.ts deleted file mode 100644 index 10f4bbe..0000000 --- a/src/domain/models/ingestSnapshot/ops.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Either } from "effect" - -import { - isNonEmptyString, - type RunIdentity, - type SegmentRecord, - type SelectedSnapshot, - type SnapshotIdentity, - type TrustedCanonicalProjectionPath, - type TrustedManifestPath, - type TrustedSegmentsPath, - type TrustedSummaryPath, -} from "./shared.js" -import { - foldFailure, - makeAstNodeKind, - makeDerivedRunIdentity, - makeNormalizedHash, - makeRawHash, - makeRunIdentity, - makeShapeHash, - makeTrustedBundleLocation, - makeTrustedCanonicalProjectionPath, - makeTrustedManifestPath, - makeTrustedSegmentsPath, - makeTrustedSummaryPath, -} from "./factories.js" -import type { - DerivedRunIdentity, - Error, - RequiredArtifact, - TaintedBundleInput, -} from "./types.js" - -const parseBundleLocationText = (location: string): string | null => { - const trimmedLocation = location.trim() - return trimmedLocation.length === 0 || !trimmedLocation.includes("/") - ? null - : trimmedLocation -} - -const decideSegmentRecordFailure = ( - selectedSnapshot: SelectedSnapshot, - bundleLocation: string, -): Error | null => { - if (bundleLocation.includes("too-large")) { - return foldFailure(selectedSnapshot.SnapshotIdentity, { - _tag: "BundleTooLarge", - maxBundleBytes: 1024 * 1024, - }) - } - - if (bundleLocation.includes("budget-exceeded")) { - return foldFailure(selectedSnapshot.SnapshotIdentity, { - _tag: "ParseBudgetExceeded", - parseBudget: 50_000, - }) - } - - return null -} - -export const parseBundleLocation = ( - snapshotIdentity: SnapshotIdentity, - input: TaintedBundleInput, -): Either.Either, Error> => { - const location = parseBundleLocationText(input.location as string) - return location === null - ? Either.left(foldFailure(snapshotIdentity, "BundleNotParseable")) - : Either.right(makeTrustedBundleLocation(location)) -} - -export const applyRunIdentityRules = ( - selectedSnapshot: SelectedSnapshot, -): Either.Either => { - const snapshotIdentity = selectedSnapshot.SnapshotIdentity as string - return isNonEmptyString(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 - const failure = decideSegmentRecordFailure(selectedSnapshot, bundleLocation) - - if (failure) { - return Either.left(failure) - } - - 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> => { - const firstSegment = segmentRecords[0] - return firstSegment - ? Either.right([`boundary:${firstSegment.SegmentId}`]) - : Either.left(foldFailure(snapshotIdentity, "NoDeterministicBoundaryProven")) -} - -export const validateRequiredArtifacts = ( - snapshotIdentity: SnapshotIdentity, - requiredArtifacts: ReadonlyArray, - segmentRecords: ReadonlyArray, -): Either.Either, Error> => { - const missingArtifact = segmentRecords[0] ? null : requiredArtifacts[0] - return missingArtifact === null - ? Either.right(requiredArtifacts) - : Either.left( - foldFailure(snapshotIdentity, { - _tag: "RequiredArtifactMissing", - artifact: missingArtifact ?? "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/domain/models/ingestSnapshot/shared.ts b/src/domain/models/ingestSnapshot/shared.ts deleted file mode 100644 index ea18397..0000000 --- a/src/domain/models/ingestSnapshot/shared.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Schema } from "@effect/schema" - -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 const isNonEmptyString = (value: string): boolean => - Schema.is(NonEmptyString)(value) diff --git a/src/domain/models/ingestSnapshot/types.ts b/src/domain/models/ingestSnapshot/types.ts deleted file mode 100644 index 86dbbc9..0000000 --- a/src/domain/models/ingestSnapshot/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { - RunIdentity, - RunManifest, - SegmentRecord, - SelectedSnapshot, - SnapshotIdentity, - SnapshotMetadata, - TrustedCanonicalProjectionPath, - TrustedSummaryPath, - TaintedBundleLocation, -} from "./shared.js" - -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) diff --git a/src/policies/ingestSnapshot/segments.ts b/src/policies/ingestSnapshot/segments.ts deleted file mode 100644 index 9a8eca7..0000000 --- a/src/policies/ingestSnapshot/segments.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Either } from "effect" - -import { - type DeterministicSegmentsReady, - type Error, - type SnapshotReady, - applyRunIdentityRules, - validateBoundaryProofs, - validateRequiredArtifacts, - validateSegmentRecords, -} from "../../domain/models/IngestSnapshot.js" - -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, - }), - ), - ), - ), - ) diff --git a/src/policies/ingestSnapshot/selection.ts b/src/policies/ingestSnapshot/selection.ts deleted file mode 100644 index 9f49f2d..0000000 --- a/src/policies/ingestSnapshot/selection.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Either } from "effect" - -import { - type Error, - type IngestUpstreamSnapshot, - type RunManifest, - type SnapshotReady, - type State, - foldFailure, - makeVerifiedPreviousRunManifest, - parseBundleLocation, -} 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, - }), - ) -} diff --git a/src/workflows/ingestSnapshot.ts b/src/workflows/ingestSnapshot.ts deleted file mode 100644 index 513f21d..0000000 --- a/src/workflows/ingestSnapshot.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 index 7848ab5..7c57ece 100644 --- a/test/ingestSnapshot.test.ts +++ b/test/ingestSnapshot.test.ts @@ -16,14 +16,14 @@ import { makeTrustedSegmentsPath, makeTrustedSummaryPath, makeVerifiedPreviousRunManifest, -} from "../src/domain/models/IngestSnapshot.js" +} from "../src/contexts/ingest-snapshot/index.js" import { apply, decide, makeAwaitingSnapshotSelection, validatePreviousRunManifest, -} from "../src/policies/decideSnapshotIngest.js" -import { workflow } from "../src/workflows/ingestSnapshot.js" + workflow, +} from "../src/contexts/ingest-snapshot/index.js" const makeCommand = ( overrides: Partial = {},