Skip to main content

Repeatable Lab Runs

Use this walkthrough when you need the same synthetic enterprise more than once: demos, regression tests, lab refreshes, adapter validation, or before-and-after comparisons.

The repeatable pattern is simple:

  1. keep the scenario JSON and catalog baseline under source control
  2. use an explicit seed and fixed generation, snapshot, and export times
  3. save a .seworld snapshot and export normalized artifacts with one manifest
  4. use the receipt tooling when an independent equality check is needed
  5. reset or apply deltas deliberately

Requirements

  • DataGen module imported
  • committed scenario JSON
  • the same DataGen version, source identity, catalog content, scenario content, seed, fixed times, and public arguments for both runs
  • a writable output root, plus two absent or empty candidate directories for receipt work
  • a downstream lab reset plan if you are applying the output to AD, Entra, CMDB, or another system

1. Choose fixed inputs

$scenarioPath = Resolve-Path .\general-enterprise-lab.json
$seed = 4242
$generatedAt = [DateTimeOffset]'2026-08-07T12:00:00Z'
$snapshotId = [Guid]'a4eebcf9-bb33-4d57-a756-612cc0e3d06d'
$runId = "general-enterprise-$seed"
$outputRoot = Join-Path $PWD "out\$runId"

Test-SEScenario -Path $scenarioPath

The scenario controls what is generated. The seed controls deterministic choices. For a reproducibility check, fix the generation, snapshot, and export times too; a same-seed run made at a different time is a different input.

2. Generate, snapshot, and export

New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null

$world = New-SEEnterpriseWorld -ScenarioPath $scenarioPath -Seed $seed -GeneratedAt $generatedAt
$world | Get-SEWorldSummary

$snapshotPath = Join-Path $outputRoot "$runId.seworld"
$exportPath = Join-Path $outputRoot 'normalized'

$world | Save-SEEnterpriseWorld `
-Path $snapshotPath `
-Compress `
-SavedAt $generatedAt `
-SnapshotId $snapshotId

$world | Export-SEEnterpriseWorld `
-OutputPath $exportPath `
-Format Json `
-Profile Normalized `
-IncludeManifest `
-IncludeSummary `
-ExportedAtUtc $generatedAt `
-Overwrite

Keep the snapshot when you need to inspect the same world later without regenerating it. The normalized export is for another system that needs files. In v0.9.3 the export uses canonical ordinal ordering and normalized newlines across supported platforms; retain its manifest.json as the one export identity alongside the snapshot and input record.

3. Prove that the snapshot round-trips

$imported = Import-SEEnterpriseWorld -Path $snapshotPath
$imported | Get-SEWorldSummary

The round-trip check catches broken snapshots before you hand the artifact to another operator or pipeline.

4. Produce an independent-run receipt

Use this procedure when a plain file comparison is not enough. It runs the same generation adapter twice in separate PowerShell processes, binds each run to a preissued contract, and writes one receipt outside both candidate roots.

The example uses Windows paths because the tools are PowerShell 7 scripts. On Linux or macOS, set $repoRoot and $workingRoot to absolute POSIX paths; Join-Path keeps the remaining path construction portable. The Git and .NET executable paths must be fully qualified.

# Windows example. The working root may exist, but candidate-a and candidate-b
# must not already exist or must be empty.
$repoRoot = (Get-Location).Path
$workingRoot = Join-Path ([IO.Path]::GetTempPath()) 'DataGen-v0.9.3-evidence'
New-Item -ItemType Directory -Force -Path $workingRoot | Out-Null

$pwshPath = (Get-Command pwsh -ErrorAction Stop).Source
$gitPath = (Get-Command git -ErrorAction Stop).Source
$dotnetPath = (Get-Command dotnet -ErrorAction Stop).Source
$scenarioPath = Join-Path $repoRoot 'examples\regional_manufacturer.scenario.json'
$generationScriptPath = Join-Path $workingRoot 'generate-normalized-artifacts.ps1'
$generatedAt = [DateTimeOffset]'2026-08-07T12:00:00Z'
$snapshotId = [Guid]'a4eebcf9-bb33-4d57-a756-612cc0e3d06d'
$candidateA = Join-Path $workingRoot 'candidate-a'
$candidateB = Join-Path $workingRoot 'candidate-b'
$contractAPath = Join-Path $workingRoot 'candidate-a.contract.json'
$contractBPath = Join-Path $workingRoot 'candidate-b.contract.json'
$receiptPath = Join-Path $workingRoot 'determinism-receipt.json'

if ((Test-Path -LiteralPath $candidateA) -or (Test-Path -LiteralPath $candidateB)) {
throw 'Candidate roots must be absent or manually emptied before this evidence run.'
}

The wrapper requires a generation script with four standard parameters. This adapter produces a portable snapshot and canonical normalized export in its assigned candidate root. It assumes the v0.9.3 PowerShell module is installed and discoverable by PowerShell.

@'
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$OutputPath,
[Parameter(Mandatory)][string]$ScenarioPath,
[Parameter(Mandatory)][int]$Seed,
[Parameter(Mandatory)][DateTimeOffset]$GeneratedAt
)

$ErrorActionPreference = 'Stop'
Import-Module SyntheticEnterprise.PowerShell -Force
$snapshotId = [Guid]'a4eebcf9-bb33-4d57-a756-612cc0e3d06d'
$world = New-SEEnterpriseWorld -ScenarioPath $ScenarioPath -Seed $Seed -GeneratedAt $GeneratedAt
$world | Save-SEEnterpriseWorld -Path (Join-Path $OutputPath 'world.seworld') -Compress -SavedAt $GeneratedAt -SnapshotId $snapshotId
$world | Export-SEEnterpriseWorld -OutputPath (Join-Path $OutputPath 'normalized') -Format Json -Profile Normalized -IncludeManifest -IncludeSummary -ExportedAtUtc $GeneratedAt -CredentialExportMode Masked -Overwrite
'@ | Set-Content -LiteralPath $generationScriptPath -Encoding utf8NoBOM

$contractParameters = @{
ScenarioPath = $scenarioPath
ExpectedGenerationScriptPath = $generationScriptPath
Seed = 4242
GeneratedAt = $generatedAt
RepoRoot = $repoRoot
GitPath = $gitPath
DotNetPath = $dotnetPath
}

$contractA = & (Join-Path $repoRoot 'scripts\new-generation-invocation-contract.ps1') @contractParameters -ChallengeLabel candidate-1 -OutputPath $contractAPath
$contractB = & (Join-Path $repoRoot 'scripts\new-generation-invocation-contract.ps1') @contractParameters -ChallengeLabel candidate-2 -OutputPath $contractBPath

Run the wrapper once per candidate. Each pwsh invocation is a separate process. Do not copy one candidate to make the second; the receipt checks the individual parent challenges and process identities.

$wrapperPath = Join-Path $repoRoot 'scripts\invoke-deterministic-generation.ps1'

& $pwshPath -NoProfile -File $wrapperPath -CandidatePath $candidateA -ScenarioPath $scenarioPath -Seed 4242 -GeneratedAt $generatedAt -GenerationScriptPath $generationScriptPath -InvocationContractPath $contractAPath -GitPath $gitPath -DotNetPath $dotnetPath -RepoRoot $repoRoot
& $pwshPath -NoProfile -File $wrapperPath -CandidatePath $candidateB -ScenarioPath $scenarioPath -Seed 4242 -GeneratedAt $generatedAt -GenerationScriptPath $generationScriptPath -InvocationContractPath $contractBPath -GitPath $gitPath -DotNetPath $dotnetPath -RepoRoot $repoRoot

$receipt = & (Join-Path $repoRoot 'scripts\new-determinism-receipt.ps1') `
-CandidatePath $candidateA, $candidateB `
-ScenarioPath $scenarioPath `
-Seed 4242 `
-GeneratedAt $generatedAt `
-OutputPath $receiptPath `
-ExpectedGenerationScriptPath $generationScriptPath `
-ExpectedInvocationInputDigest $contractA.expected.invocation.argumentDigestSha256 `
-ExpectedInvocationContractPath $contractAPath, $contractBPath `
-GitPath $gitPath `
-DotNetPath $dotnetPath `
-RepoRoot $repoRoot `
-FailOnMismatch

$receipt.passed
Get-Content -LiteralPath $receiptPath -Raw

$receipt.passed is True only when both payload inventories have the same canonical hash, file count, and byte count. The receipt uses candidate-1 and candidate-2 labels and relative artifact paths, so it can travel without exposing either candidate's absolute root.

Sensitive inputs and the trust boundary

The receipt is trusted-operator, unsigned QA evidence. It detects an accidental rerun, changed scenario or public argument, stale source/runtime/tool identity, copied candidate, and payload change. It does not authenticate the operator or provide cryptographic tamper attestation; someone with write access can alter public contracts, sidecars, and receipts.

For an adapter with a secret-bearing option, declare the option on all three tools. For example, an adapter that accepts -BootstrapPassword would pass -GenerationArgumentList @('-BootstrapPassword', $env:DATAGEN_BOOTSTRAP_PASSWORD) and -SensitiveGenerationArgumentName BootstrapPassword when issuing each contract and invoking each wrapper. When creating the receipt, pass the same values as -ExpectedGenerationArgumentList @('-BootstrapPassword', $env:DATAGEN_BOOTSTRAP_PASSWORD) and -SensitiveGenerationArgumentName BootstrapPassword. The value is replaced before the contract, sidecar, and receipt are serialized or hashed.

That redaction is intentional: sensitive inputs are excluded from the equality proof. Keep generated passwords, access tokens, and similar material out of deterministic fixture comparisons. Final artifacts can differ when credential material is intentionally generated, even with the same scenario, seed, fixed times, DataGen version, source, and catalog.

5. Compare two runs without a receipt

For an informal comparison, generate a second run with the same fixed inputs and compare the normalized artifacts.

$runA = Resolve-Path .\out\general-enterprise-4242\normalized
$runB = Resolve-Path .\out\general-enterprise-4242-rerun\normalized

$filesA = Get-ChildItem $runA -Recurse -File | ForEach-Object {
[pscustomobject]@{
RelativePath = $_.FullName.Substring($runA.Path.Length).TrimStart('\')
Hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash
}
}
$filesB = Get-ChildItem $runB -Recurse -File | ForEach-Object {
[pscustomobject]@{
RelativePath = $_.FullName.Substring($runB.Path.Length).TrimStart('\')
Hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash
}
}

Compare-Object $filesA $filesB -Property RelativePath, Hash

An empty comparison means the selected files match. If there are differences, first check the scenario, seed, fixed times, DataGen version, source, catalog, public arguments, export profile, and credential mode.

6. Refresh a populated lab

Pick a refresh strategy before applying new output:

  • Reset and replay: delete the dedicated lab OU, remove prefixed tenant objects, restore a VM checkpoint, or rebuild the target database, then apply the new export.
  • Compare and approve: compare the old and new exports, review changed entities and memberships, then apply only the approved changes.
  • Snapshot freeze: keep the .seworld file as the demo fixture and export from that snapshot whenever you need fresh files.

For AD and Entra labs, reset-and-replay is usually the cleanest pattern. It avoids accumulating stale accounts, duplicate groups, and old memberships that no longer reflect the generated world.

7. Re-export from a frozen snapshot

$world = Import-SEEnterpriseWorld -Path .\out\general-enterprise-4242\general-enterprise-4242.seworld
$exportedAt = [DateTimeOffset]'2026-08-07T12:00:00Z'

$world | Export-SEEnterpriseWorld `
-OutputPath .\out\general-enterprise-4242\normalized-refresh `
-Format Json `
-Profile Normalized `
-IncludeManifest `
-IncludeSummary `
-ExportedAtUtc $exportedAt `
-Overwrite

This is useful when the downstream adapter wants a clean export folder but the world itself should not change.

8. Automate the repeatable run

Make scenario path, seed, timestamps, catalog identity, and output root explicit parameters. A good automation run records:

  • scenario path and hash
  • seed and fixed timestamps
  • DataGen version, source identity, and catalog identity
  • snapshot path
  • normalized export and manifest paths
  • receipt path and result when independent evidence is required
  • world summary and any approved delta

That gives the next operator enough evidence to know whether they are looking at the intended lab state.